mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Merge pull request #367 from shenlvkang-collab/pr/claude-conversation-first-hand
fix(session): learn the live Claude conversation from the CLI's own hook
This commit is contained in:
@@ -115,6 +115,75 @@ describe('hook-event-routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The pane's live conversation id, reported by the CLI process itself. This
|
||||
* is what lets the response viewer stop guessing from ~/.claude/history.jsonl
|
||||
* — a guess that could never run at all for a pane the user drives by
|
||||
* attaching to tmux, because `lastSubmitAt` only ever saw Codeman's own
|
||||
* write path.
|
||||
*/
|
||||
it('adopts the conversation id first-hand from a prompt_submitted hook', async () => {
|
||||
const session = harness.ctx._session;
|
||||
const before = session.lastSubmitAt;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'prompt_submitted',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: { hook_event_name: 'UserPromptSubmit', session_id: 'conv-1', source: 'user' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(session.claudeSessionId).toBe('conv-1');
|
||||
expect(session.claudeSessionIdIsFirstHand).toBe(true);
|
||||
expect(session.claudeSessionChain).toEqual(['conv-1']);
|
||||
expect(session.lastSubmitAt).toBeGreaterThan(before);
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
|
||||
});
|
||||
|
||||
it('records a /clear successor in the chain and persists it, without duplicating a repeat', async () => {
|
||||
const session = harness.ctx._session;
|
||||
const submit = async (conversationId: string) =>
|
||||
harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'prompt_submitted',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: { hook_event_name: 'UserPromptSubmit', session_id: conversationId },
|
||||
},
|
||||
});
|
||||
|
||||
await submit('conv-1');
|
||||
await submit('conv-1'); // every prompt in a conversation reports the same id
|
||||
await submit('conv-2'); // the user ran /clear
|
||||
|
||||
expect(session.claudeSessionChain).toEqual(['conv-1', 'conv-2']);
|
||||
expect(session.claudeSessionId).toBe('conv-2');
|
||||
// `/clear` emits no completion event, so the successor is lost on restart
|
||||
// unless the hook itself persists it.
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not leak the prompt text into the broadcast', async () => {
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'prompt_submitted',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: { hook_event_name: 'UserPromptSubmit', session_id: 'conv-1', prompt: 'my secret prompt' },
|
||||
},
|
||||
});
|
||||
|
||||
const broadcast = JSON.stringify(harness.ctx.broadcast.mock.calls);
|
||||
expect(broadcast).not.toContain('my secret prompt');
|
||||
expect(broadcast).toContain('conv-1');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -426,16 +426,18 @@ describe('GET /api/sessions/:id/last-response (claude conversation pinning)', ()
|
||||
}
|
||||
|
||||
/** Replaces the pre-seeded mock session with a Claude pane in WORKDIR. */
|
||||
function addPane(id: string, conversationId: string, lastSubmitAt: number) {
|
||||
function addPane(id: string, conversationId: string, lastSubmitAt: number, firstHand = false) {
|
||||
const base = harness.ctx._session;
|
||||
const pane = Object.create(Object.getPrototypeOf(base)) as typeof base & {
|
||||
claudeSessionId: string;
|
||||
lastSubmitAt: number;
|
||||
claudeSessionIdIsFirstHand: boolean;
|
||||
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
Object.assign(pane, base, { id, mode: 'claude', workingDir: WORKDIR, docker: undefined });
|
||||
pane.claudeSessionId = conversationId;
|
||||
pane.lastSubmitAt = lastSubmitAt;
|
||||
pane.claudeSessionIdIsFirstHand = firstHand;
|
||||
pane.adoptClaudeSessionId = vi.fn((newId: string) => {
|
||||
pane.claudeSessionId = newId;
|
||||
});
|
||||
@@ -480,6 +482,41 @@ describe('GET /api/sessions/:id/last-response (claude conversation pinning)', ()
|
||||
expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* The whole point of the UserPromptSubmit hook: a pane driven by attaching to
|
||||
* tmux directly never bumps `lastSubmitAt` (only Codeman's own write path
|
||||
* does), so before this the correlation could not run at all for it and the
|
||||
* viewer stayed pinned to the launch conversation for the pane's whole life.
|
||||
*/
|
||||
it("trusts the pane's own hook over any history correlation", async () => {
|
||||
const pane = addPane('pane-1', 'hook-conversation', 0, true);
|
||||
writeTranscript('hook-conversation', 'the answer this pane gave', NOW - 60_000);
|
||||
// A newer, closer entry that the correlation would otherwise have claimed.
|
||||
writeTranscript('decoy-conversation', 'a stranger answer', NOW);
|
||||
writeHistory([{ sessionId: 'decoy-conversation', timestamp: NOW }]);
|
||||
|
||||
expect(await getLastResponse('pane-1')).toEqual({
|
||||
text: 'the answer this pane gave',
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never lets a correlation override a first-hand id, even a well-anchored one', async () => {
|
||||
// Same shape as the /clear-following test above, which DOES adopt — the only
|
||||
// difference is that this pane's id came from its own hook.
|
||||
const pane = addPane('pane-1', 'before-clear', NOW, true);
|
||||
writeTranscript('before-clear', 'answer before clear', NOW - 60_000);
|
||||
writeTranscript('after-clear', 'answer after clear', NOW + 500);
|
||||
writeHistory([{ sessionId: 'after-clear', timestamp: NOW + 120 }]);
|
||||
|
||||
expect(await getLastResponse('pane-1')).toEqual({
|
||||
text: 'answer before clear',
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('credits a shared-cwd entry to the pane whose Enter is closest to it', async () => {
|
||||
const near = addPane('pane-near', 'near-conversation', NOW);
|
||||
const far = addPane('pane-far', 'far-conversation', NOW - 4_000);
|
||||
|
||||
Reference in New Issue
Block a user