Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/_agent-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ permissions:

jobs:
check:
name: Check agent-sdk
runs-on: ubuntu-latest
name: Check agent-sdk (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
defaults:
run:
working-directory: agent-sdk
Expand All @@ -29,7 +33,7 @@ jobs:
run: npm run build

- name: Run tests
run: npm run test
run: node --test 'dist/**/*.test.js'

- name: Run lint
run: npm run lint
Expand Down
58 changes: 55 additions & 3 deletions agent-sdk/src/bridge.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,11 @@ test("production bridge source stays on the public main SDK export", () => {
function ultracodeFixtureBridge(env: NodeJS.ProcessEnv = {}): { bridge: SpawnedBridge; cleanup: () => Promise<void> } {
const directory = mkdtempSync(join(tmpdir(), "claude-rs-ultracode-"));
const sdkPath = import.meta.resolve("@anthropic-ai/claude-agent-sdk");
const sdkPackage = join(dirname(fileURLToPath(sdkPath)), "package.json");
writeFileSync(
join(directory, "package.json"),
JSON.stringify({ version: JSON.parse(readFileSync(sdkPackage, "utf8")).version }),
);
const fixturePath = join(directory, "sdk-fixture.mjs");
writeFileSync(fixturePath, `
export * from ${JSON.stringify(sdkPath)};
Expand All @@ -607,17 +612,35 @@ function ultracodeFixtureBridge(env: NodeJS.ProcessEnv = {}): { bridge: SpawnedB
return sdkResolveSettings({ ...options, ...(managedSettings ? { serverManagedSettings: JSON.parse(managedSettings) } : {}) });
}
function record(value) { if (process.env.STARTUP_JOURNAL) appendFileSync(process.env.STARTUP_JOURNAL, JSON.stringify(value) + "\\n"); }
const saved = new Map();
function sessionEntries() {
const seeded = JSON.parse(process.env.STARTUP_SESSIONS_FIXTURE ?? "[]");
const entries = new Map(seeded.map(entry => [entry.sessionId, entry]));
for (const [sessionId, state] of saved) {
const entry = entries.get(sessionId) ?? { sessionId, cwd: state.cwd, lastModified: 1 };
entries.set(sessionId, { ...entry, ...(Object.hasOwn(state, "title") ? { customTitle: state.title } : {}) });
}
return [...entries.values()];
}
export async function listSessions(options) {
record({ type: "list", options });
const seeded = JSON.parse(process.env.STARTUP_SESSIONS_FIXTURE ?? "[]");
return [...seeded, ...[...saved.keys()].map((sessionId, index) => ({ sessionId, cwd: saved.get(sessionId).cwd, lastModified: index + 1 }))].filter(entry => !options?.dir || entry.cwd === options.dir);
return sessionEntries().filter(entry => !options?.dir || entry.cwd === options.dir);
}
export async function getSessionInfo(sessionId, options) {
const entry = sessionEntries().find(candidate => candidate.sessionId === sessionId);
if (!entry || (options?.dir && entry.cwd !== options.dir)) return undefined;
return entry;
}
export async function getSessionMessages(...args) { return process.env.RESUME_TRANSCRIPT_FIXTURE === "1" ? sdkGetSessionMessages(...args) : []; }
export async function importSessionToStore(...args) { if (process.env.RESUME_TRANSCRIPT_FIXTURE === "1") await sdkImportSessionToStore(...args); }
const saved = new Map();
export async function renameSession(sessionId, title, options) {
const existing = saved.get(sessionId) ?? {};
saved.set(sessionId, { ...existing, cwd: existing.cwd ?? options?.dir, title });
}
export function query({ prompt, options }) {
record({ type: "query", cwd: options.cwd, resume: options.resume, model: options.model, effort: options.effort, permissionMode: options.permissionMode, agent: options.agent });
const state = saved.get(options.resume) ?? { requested: false, effort: "high", cwd: options.cwd };
if (options.title !== undefined) state.title = options.title;
saved.set(options.sessionId ?? options.resume, state);
let model = options.model ?? "opus";
let effective = {};
Expand Down Expand Up @@ -900,6 +923,35 @@ test("continue with no previous session starts a new session", async () => {
} finally { await cleanup(); }
});

test("fixture session info preserves and clears a seeded title", async () => {
const cwd = process.cwd();
const sessionId = "seeded-session";
const { bridge, cleanup } = ultracodeFixtureBridge({
STARTUP_SESSIONS_FIXTURE: JSON.stringify([
{ sessionId, cwd, lastModified: 1, customTitle: "Saved title" },
]),
});
try {
bridge.writeCommand({ command: "resume_session", session_id: sessionId, launch_settings: {} });
await nextMatching(bridge, event => event.event === "connected");
const restored = await nextMatching(bridge, event => {
const update = event.update as SessionUpdate | undefined;
return update?.type === "session_title_update";
});
assert.equal((restored.update as SessionUpdate & { type: "session_title_update" }).title, "Saved title");

bridge.writeCommand({ command: "rename_session", session_id: sessionId, title: "", request_id: "clear-title" });
const cleared = await nextMatching(bridge, event => {
const update = event.update as SessionUpdate | undefined;
return update?.type === "session_title_update";
});
assert.equal((cleared.update as SessionUpdate & { type: "session_title_update" }).title, "");
const listed = await nextMatching(bridge, event => event.event === "sessions_listed" && event.request_id === "clear-title");
const saved = (listed.sessions as BridgeEnvelope[]).find(session => session.session_id === sessionId);
assert.equal(saved?.custom_title, undefined);
} finally { await cleanup(); }
});

test("spawned bridge reports applied effort at startup, after caps and model changes, per turn and after resume", async () => {
const { bridge, cleanup } = ultracodeFixtureBridge({ EFFORT_FIXTURE_CAP: "high" });
const nextEffort = async (value: unknown) => {
Expand Down
3 changes: 3 additions & 0 deletions agent-sdk/src/bridge/command_session_data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
writeEvent,
} from "./events.js";
import { bridgeLogger, LOG_TARGETS } from "./logger.js";
import { emitSessionTitle } from "./session_title.js";
import {
refreshCurrentModel,
sessionById,
Expand Down Expand Up @@ -111,6 +112,7 @@ async function generateTitle(
session.query,
command.description,
);
await emitSessionTitle(session);
setSessionListingDir(session.cwd);
await emitSessionsList(requestId);
} catch (error) {
Expand Down Expand Up @@ -138,6 +140,7 @@ async function rename(
command.title,
deps.buildSessionMutationOptions(session.cwd),
);
await emitSessionTitle(session);
setSessionListingDir(session.cwd);
await emitSessionsList(requestId);
} catch (error) {
Expand Down
3 changes: 3 additions & 0 deletions agent-sdk/src/bridge/events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { emitSessionTitle } from "./session_title.js";
import {
listSessions,
type ListSessionsOptions,
Expand Down Expand Up @@ -389,6 +390,7 @@ export function emitConnectEvent(session: SessionState): void {
}
}
emitAvailableAgentsSnapshot(session);
void emitSessionTitle(session);
session.resumeUpdates = undefined;
session.restoredInput = undefined;

Expand Down Expand Up @@ -420,6 +422,7 @@ export function emitSessionReplacedEvent(
writeEvent(bridgeEvent, requestId);
emitAvailableCommandsSnapshot(session);
emitAvailableAgentsSnapshot(session);
void emitSessionTitle(session);
if (session.pendingRewindResult) {
writeEvent(
{ ...session.pendingRewindResult, session_id: session.sessionId },
Expand Down
5 changes: 3 additions & 2 deletions agent-sdk/src/bridge/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ test("resume display restores compaction segments once and preserves SDK branch
try {
const cwd = join(directory, "project");
const configDir = join(directory, "config");
const projectDir = join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
const projectDirName = "history-export-fixture";
const projectDir = join(configDir, "projects", projectDirName);
mkdirSync(cwd, { recursive: true });
mkdirSync(projectDir, { recursive: true });
const sessionId = "11111111-1111-4111-8111-111111111111";
Expand Down Expand Up @@ -50,7 +51,7 @@ test("resume display restores compaction segments once and preserves SDK branch
const child = spawnSync(process.execPath, ["--input-type=module", "-"], {
input: script,
encoding: "utf8",
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_PROJECT_DIR_NAME: undefined },
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_PROJECT_DIR_NAME: projectDirName },
windowsHide: true,
});
assert.equal(child.status, 0, child.stderr);
Expand Down
5 changes: 5 additions & 0 deletions agent-sdk/src/bridge/message_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { nativeNotification } from "./notifications.js";
import { elapsedNumber, messageMetadata, turnTiming } from "./presentation_metadata.js";
import { refreshUltracode } from "./ultracode.js";
import { observeSessionEffort, refreshSessionEffort } from "./effort.js";
import { emitSessionTitle } from "./session_title.js";
import { observeSessionModel } from "./session_model.js";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type {
Expand Down Expand Up @@ -1569,6 +1570,7 @@ export function handleResultMessage(
message.parent_tool_use_id === undefined
) {
emitUserMessageStarted(session, message, "result");
void emitSessionTitle(session);
}
emitFastModeUpdateIfChanged(
session,
Expand Down Expand Up @@ -1741,6 +1743,9 @@ export function handleSdkMessage(
timestamp: trimmedStringField(msg, "timestamp"),
user_message_uuid: trimmedStringField(msg, "user_message_uuid"),
});
// The app drops the title with the conversation; send what the API
// reports for the session after the reset.
void emitSessionTitle(session);
return;
}

Expand Down
2 changes: 2 additions & 0 deletions agent-sdk/src/bridge/session_lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ export type SessionState = {
resumeUpdates?: SessionUpdate[];
restoredInput?: string;
pendingRewindResult?: PendingRewindResult;
/** Counts title reads, so only the latest one is sent. */
titleReads?: number;
};

export const sessions = new Map<string, SessionState>();
Expand Down
Loading
Loading