mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
fix(sessions): stop pinning the w1-myapp placeholder as Claude's /resume title (#457)
* fix(sessions): stop pinning the w1-myapp placeholder as Claude's /resume title Local claude spawns passed the tab name as `--name`. That flag is not only the cross-session peer name: it is also the prompt-box label, the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own (`customTitle ?? aiTitle`). So every conversation of a case was listed in `/resume` as the same `w1-myapp`, and none of them got a generated title. On one workspace, 34 of 34 conversations spawned with `--name` had no ai-title, while every conversation spawned without it had one. Only a name the user chose is pinned now: `Session.cliPinnedName` is the name when `nameSource === 'manual'`, carried to the builders as a separate `cliName` so the tab/mux name is untouched. Placeholder and auto names let Claude title the conversation again. A rename in Codeman also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (never creating the file, never writing an empty title). For a pane spawned without `--name` this holds immediately; a pane spawned with one re-appends its own title each turn, so there the new name holds from the next spawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): skip no-op renames and docker sessions when syncing the /resume title A same-name PUT (the Session Options field saves on blur and recomposes the unchanged placeholder) no longer flips nameSource to manual or appends a custom-title row, and docker sessions skip the host transcript scan since their transcript lives in the container. The skill pages no longer use a w<N>- name as the peer-name example, and the changeset notes the re-append caveat. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: record that nameSource decides --name and renames reach /resume Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: codeman-local <codeman@local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
codeman-local
parent
7485afecaf
commit
69a71287e6
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @fileoverview Claude's `/resume` title belongs to Claude unless the user chose one.
|
||||
*
|
||||
* `--name` sets the prompt-box label, the `/resume` picker entry and the terminal
|
||||
* title, and a pinned title stops Claude generating its own. Pinning the
|
||||
* `w1-myapp` placeholder therefore listed every conversation of a case under the
|
||||
* same name in `/resume`. Only a manual name is pinned now (`cliPinnedName`), and
|
||||
* a rename reaches the transcript as a `custom-title` row.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { appendClaudeCustomTitle } from '../src/claude-session-title.js';
|
||||
|
||||
type RespawnOptionsProbe = { _buildRespawnPaneOptions(): { name?: string; cliName?: string } };
|
||||
|
||||
describe('Session.cliPinnedName', () => {
|
||||
it('pins nothing for a placeholder, so Claude titles the conversation itself', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.cliPinnedName).toBeUndefined();
|
||||
const options = (session as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions();
|
||||
// The tab keeps its name; only the CLI flag is withheld.
|
||||
expect(options.name).toBe('w1-demo');
|
||||
expect(options.cliName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins nothing for an auto name, whose cut of the prompt Claude beats', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
|
||||
expect(session.cliPinnedName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins a name the user chose, at creation or by a rename', () => {
|
||||
expect(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }).cliPinnedName).toBe('msgtest-worker');
|
||||
|
||||
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
renamed.name = '登录修复';
|
||||
expect(renamed.cliPinnedName).toBe('登录修复');
|
||||
expect((renamed as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions().cliName).toBe('登录修复');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendClaudeCustomTitle', () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
const tempTranscript = (content: string) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-claude-title-'));
|
||||
dirs.push(dir);
|
||||
const path = join(dir, 'conv.jsonl');
|
||||
if (content) writeFileSync(path, content);
|
||||
return path;
|
||||
};
|
||||
|
||||
it('appends one custom-title row after the existing rows', async () => {
|
||||
const path = tempTranscript('{"type":"user"}\n');
|
||||
expect(await appendClaudeCustomTitle(path, 'conv', ' release notes "v2" ')).toBe(true);
|
||||
const lines = readFileSync(path, 'utf8').split('\n');
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[0]).toBe('{"type":"user"}');
|
||||
expect(JSON.parse(lines[1])).toEqual({
|
||||
type: 'custom-title',
|
||||
customTitle: 'release notes "v2"',
|
||||
sessionId: 'conv',
|
||||
});
|
||||
expect(lines[2]).toBe('');
|
||||
});
|
||||
|
||||
it('never creates a transcript that does not exist yet', async () => {
|
||||
const path = tempTranscript('');
|
||||
expect(await appendClaudeCustomTitle(path, 'conv', 'title')).toBe(false);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
});
|
||||
|
||||
it('writes nothing for a blank title', async () => {
|
||||
const path = tempTranscript('{"type":"user"}\n');
|
||||
expect(await appendClaudeCustomTitle(path, 'conv', ' ')).toBe(false);
|
||||
expect(readFileSync(path, 'utf8')).toBe('{"type":"user"}\n');
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,16 @@
|
||||
* auto-naming can never overwrite a name a person chose, on this server or
|
||||
* on the one that restores the session after a restart.
|
||||
*
|
||||
* The rename also reaches Claude's own `/resume` title: a `custom-title` row is
|
||||
* appended to the conversation's transcript, the row `/rename` writes.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
@@ -18,17 +24,45 @@ describe('PUT /api/sessions/:id/name', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let session: Session;
|
||||
const updateSessionName = vi.fn(() => true);
|
||||
const transcriptDir = mkdtempSync(join(tmpdir(), 'codeman-rename-title-'));
|
||||
const transcriptPath = join(transcriptDir, '6f1c1a2e-0000-4000-8000-000000000001.jsonl');
|
||||
const transcriptRows = () =>
|
||||
readFileSync(transcriptPath, 'utf8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
|
||||
beforeAll(async () => {
|
||||
writeFileSync(transcriptPath, `${JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } })}\n`);
|
||||
harness = await createRouteTestHarness(registerSessionRoutes);
|
||||
// A REAL session, since the ownership flag lives on the class, not the mock.
|
||||
session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
|
||||
harness.ctx.sessions.set(session.id, session as never);
|
||||
(harness.ctx.mux as Record<string, unknown>).updateSessionName = updateSessionName;
|
||||
(harness.ctx as Record<string, unknown>).getTranscriptPath = (id: string) =>
|
||||
id === session.id ? transcriptPath : null;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(transcriptDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('treats a same-name PUT as a no-op: stays placeholder, writes no title row', async () => {
|
||||
// The Session Options field saves on blur and recomposes the unchanged placeholder.
|
||||
const before = transcriptRows().length;
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${session.id}/name`,
|
||||
payload: { name: 'w1-demo' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ name: 'w1-demo' });
|
||||
expect(session.nameSource).toBe('placeholder');
|
||||
expect(transcriptRows()).toHaveLength(before);
|
||||
expect(updateSessionName).not.toHaveBeenCalled();
|
||||
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
|
||||
@@ -57,4 +91,60 @@ describe('PUT /api/sessions/:id/name', () => {
|
||||
// What the restore path will read back: the persisted state carries the flag.
|
||||
expect(session.toState().nameSource).toBe('manual');
|
||||
});
|
||||
|
||||
it("appends the name as the conversation's custom-title, the row /resume reads", async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${session.id}/name`,
|
||||
payload: { name: '修复登录跳转' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(transcriptRows().at(-1)).toEqual({
|
||||
type: 'custom-title',
|
||||
customTitle: '修复登录跳转',
|
||||
sessionId: '6f1c1a2e-0000-4000-8000-000000000001',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes no title row for an empty name, which would blank the /resume entry', async () => {
|
||||
const before = transcriptRows().length;
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${session.id}/name`,
|
||||
payload: { name: ' ' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(transcriptRows()).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('appends a title row only once when the same name is PUT twice', async () => {
|
||||
const before = transcriptRows().length;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${session.id}/name`,
|
||||
payload: { name: 'twice' },
|
||||
});
|
||||
}
|
||||
expect(transcriptRows()).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it('writes no title row for a docker session, whose transcript lives in the container', async () => {
|
||||
const before = transcriptRows().length;
|
||||
Object.defineProperty(session, 'docker', { configurable: true, get: () => ({ caseName: 'c' }) });
|
||||
try {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${session.id}/name`,
|
||||
payload: { name: 'in a container' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(session.name).toBe('in a container');
|
||||
expect(transcriptRows()).toHaveLength(before);
|
||||
} finally {
|
||||
delete (session as unknown as Record<string, unknown>).docker;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user