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:
Ark0N
2026-09-06 23:02:33 +02:00
committed by GitHub
16 changed files with 540 additions and 11 deletions
+61
View File
@@ -42,6 +42,34 @@ describe('generateHooksConfig', () => {
expect(config.hooks.Stop).toHaveLength(1);
});
it('reports the live conversation id on every prompt, with stdout discarded', () => {
const config = generateHooksConfig();
expect(config.hooks.UserPromptSubmit).toBeInstanceOf(Array);
expect(config.hooks.UserPromptSubmit).toHaveLength(1);
const command = (config.hooks.UserPromptSubmit as Array<{ hooks: Array<{ command: string }> }>)[0].hooks[0].command;
expect(command).toContain('"event":"prompt_submitted"');
expect(command).toContain('$CODEMAN_SESSION_ID');
// ⚠️ Claude Code injects a UserPromptSubmit hook's stdout into the model's
// context ("Exit code 0 - stdout shown to Claude"), so without this the API
// envelope is pasted into the user's own prompt on every turn. Every other
// event's stdout is harmless (it feeds SSE).
// ⚠️ Assert curl's OWN flag, not a trailing redirect: the command already
// ends `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the
// shell binds the redirect to `true`, which never runs on the success path.
// A `endsWith('>/dev/null')` assertion passes on exactly that broken form.
expect(command).toContain('curl -sk -o /dev/null -X POST');
expect(command.trimEnd().endsWith('>/dev/null')).toBe(false);
});
it("leaves every other hook event's command text byte-identical", () => {
// The opt-in discard exists so the five SSE-fed events do not change shape:
// rewriting their command churns every workspace's settings.local.json.
const config = generateHooksConfig();
const stop = (config.hooks.Stop as Array<{ hooks: Array<{ command: string }> }>)[0].hooks[0].command;
expect(stop).toContain('curl -sk -X POST');
expect(stop).not.toContain('-o /dev/null');
});
it('should guard subagent stops while their background work is active', () => {
const config = generateHooksConfig();
const subagentHooks = config.hooks.SubagentStop as Array<{
@@ -324,6 +352,39 @@ describe('writeHooksConfig', () => {
expect(serialized).not.toContain('CODEMAN_BACKGROUND_REWAKE_V1');
});
it('heals a hooks block written before UserPromptSubmit existed', async () => {
const claudeDir = join(testDir, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
mkdirSync(claudeDir, { recursive: true });
// An otherwise-current block from the previous release: the pane would keep
// guessing its conversation from ~/.claude/history.jsonl forever.
const hooks = generateHooksConfig().hooks;
delete hooks.UserPromptSubmit;
writeFileSync(settingsPath, JSON.stringify({ hooks }, null, 2));
await refreshStaleCodemanHooks(testDir);
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(JSON.stringify(parsed.hooks.UserPromptSubmit)).toContain('prompt_submitted');
});
it('leaves an already-current hooks block untouched', async () => {
// ⚠️ The staleness gate reads a JSON.stringify'd blob, so a marker written
// with surrounding quotes never matches and the gate is permanently false —
// which rewrites every workspace's settings.local.json on every Claude
// spawn instead of never. This asserts the no-op, which is the property a
// quoted needle silently breaks.
const claudeDir = join(testDir, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
mkdirSync(claudeDir, { recursive: true });
writeFileSync(settingsPath, JSON.stringify({ hooks: generateHooksConfig().hooks }, null, 2));
const before = readFileSync(settingsPath, 'utf-8');
await refreshStaleCodemanHooks(testDir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(before);
});
it('replaces the V2 background hook without duplicating it', async () => {
const claudeDir = join(testDir, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
+26
View File
@@ -29,6 +29,32 @@ export class MockSession extends EventEmitter {
terminalBuffer: string = '';
/** Mirrors Session.lastSubmitAt — the response viewer credits history entries by it. */
lastSubmitAt: number = 0;
/** Mirrors Session.claudeSessionId — the conversation the viewer reads. */
claudeSessionId: string | null = null;
/** Mirrors Session.claudeSessionIdIsFirstHand — set only by a hook adoption. */
claudeSessionIdIsFirstHand: boolean = false;
/** Mirrors Session.claudeSessionChain — oldest first, current last. */
claudeSessionChain: string[] = [];
/** Mirrors Session.adoptClaudeSessionId, including the first-hand chain rule. */
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
if (!newId) return;
if (options.firstHand) {
this.claudeSessionIdIsFirstHand = true;
if (this.claudeSessionChain[this.claudeSessionChain.length - 1] !== newId) {
const existing = this.claudeSessionChain.indexOf(newId);
if (existing !== -1) this.claudeSessionChain.splice(existing, 1);
this.claudeSessionChain.push(newId);
}
}
if (newId === this.claudeSessionId) return;
this.claudeSessionId = newId;
}
/** Mirrors Session.markPromptSubmitted. */
markPromptSubmitted(): void {
this.lastSubmitAt = Date.now();
}
private _muxName: string | null = null;
+69
View File
@@ -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);
@@ -0,0 +1,118 @@
/**
* @fileoverview Session.claudeSessionChain — the record of which Claude
* conversations a pane has actually been on.
*
* Which conversation the response viewer reads is `Session.claudeSessionId`,
* and `start()` reassigns it to the launch id at THREE separate points. That is
* correct for a fresh pane and a lie for a re-attached one: a mux session that
* survived a Codeman restart never stopped, so the CLI may have `/clear`ed hours
* ago and moved to a conversation the launch id knows nothing about. The chain
* is what carries that across the restart, and its tail must therefore outrank
* the launch id on the restored path only.
*
* Two properties are pinned here because both were broken in ways nothing else
* caught:
*
* 1. **Only a first-hand adoption extends the chain.** The id has to come from
* the CLI's own hook payload, delivered under the pane's `$CODEMAN_SESSION_ID`.
* A history-correlated guess writing into this record would make the
* "showed a stranger's conversation" bug permanent instead of transient.
* 2. **A restored conversation survives every reset point.** The mux branch and
* the unconditional "third reset point" after it both reassign the field, so
* patching only the first leaves the restore silently undone.
*
* Port: N/A
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
describe('Session claude conversation chain', () => {
it('extends the chain only for a first-hand adoption', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
// A correlated guess: adopted for display, but never recorded.
session.adoptClaudeSessionId('guessed-conversation');
expect(session.claudeSessionId).toBe('guessed-conversation');
expect(session.claudeSessionChain).toEqual([]);
expect(session.claudeSessionIdIsFirstHand).toBe(false);
// The CLI's own hook: recorded.
session.adoptClaudeSessionId('hook-conversation', { firstHand: true });
expect(session.claudeSessionChain).toEqual(['hook-conversation']);
expect(session.claudeSessionIdIsFirstHand).toBe(true);
});
it('records a /clear successor once, however many prompts report it', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
session.adoptClaudeSessionId('conv-1', { firstHand: true });
session.adoptClaudeSessionId('conv-1', { firstHand: true }); // every prompt reports the same id
session.adoptClaudeSessionId('conv-2', { firstHand: true }); // the user ran /clear
expect(session.claudeSessionChain).toEqual(['conv-1', 'conv-2']);
expect(session.claudeSessionId).toBe('conv-2');
});
it('moves a resumed conversation to the tail instead of duplicating it', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
session.adoptClaudeSessionId('conv-1', { firstHand: true });
session.adoptClaudeSessionId('conv-2', { firstHand: true });
session.adoptClaudeSessionId('conv-1', { firstHand: true }); // /resume back
expect(session.claudeSessionChain).toEqual(['conv-2', 'conv-1']);
});
it('round-trips the chain through toState and re-pins the conversation on restore', () => {
const original = new Session({ workingDir: '/tmp', mode: 'claude' });
original.adoptClaudeSessionId('conv-1', { firstHand: true });
original.adoptClaudeSessionId('conv-2', { firstHand: true });
const state = original.toState() as { claudeSessionChain?: string[] };
expect(state.claudeSessionChain).toEqual(['conv-1', 'conv-2']);
// Boot recovery rebuilds the pane from that state. The launch id would point
// the viewer at the pre-/clear conversation; the chain's tail corrects it.
const restored = new Session({
workingDir: '/tmp',
mode: 'claude',
id: original.id,
claudeSessionChain: state.claudeSessionChain,
});
expect(restored.claudeSessionId).toBe('conv-2');
// ⚠️ NOT restored: a persisted claim is not a fact. The pane re-earns the
// guess-free path from its next hook.
expect(restored.claudeSessionIdIsFirstHand).toBe(false);
});
it('omits the chain from toState when the pane never moved conversation', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
expect((session.toState() as { claudeSessionChain?: string[] }).claudeSessionChain).toBeUndefined();
});
it('applies the restored conversation at EVERY reset point in start()', () => {
// ⚠️ Structural pin, not a behavioural one: exercising start() needs a real
// PTY and mux. start() reassigns _claudeSessionId at three points, and the
// last one runs unconditionally AFTER the mux branch — so patching only the
// mux branch leaves the restore silently undone, which is what shipped
// before this existed. Every assignment built from the launch-id fallback
// must therefore carry `restoredConversation` first.
const source = readFileSync(resolve(import.meta.dirname, '../src/session.ts'), 'utf8');
const fallbackAssignments = source.match(
/_claudeSessionId =\s*\n?\s*[^;]*?_resumeSessionId \|\| this\._ompConfig\?\.resumeSessionId \|\| this\.id;/g
);
expect(fallbackAssignments).not.toBeNull();
expect(fallbackAssignments!.length).toBeGreaterThanOrEqual(2);
for (const assignment of fallbackAssignments!) {
expect(assignment).toContain('restoredConversation ||');
}
});
it('leaves a fresh pane on its launch id', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
expect(session.claudeSessionId).toBe(session.id);
expect(session.claudeSessionChain).toEqual([]);
});
});