diff --git a/apps/claude-sdk-cli/CHANGELOG.md b/apps/claude-sdk-cli/CHANGELOG.md index e9e3b763..48a96678 100644 --- a/apps/claude-sdk-cli/CHANGELOG.md +++ b/apps/claude-sdk-cli/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A notice now prints in the conversation whenever a tool's disabled/enabled state actually flips on a config reload (e.g. AzCli/EscalatedAzCli becoming available as an account is configured) - A running session can now move to another working directory from command mode, without restarting the process +- A say over the wire can carry object-store attachments (images, PDFs): each block resolves from the bucket it names, the bytes are inlined for the model, the committed message keeps the reference block, and a say whose attachment does not resolve is rejected attachment_unavailable - Add --config flag to override any config value with a JSON object - Add --file flag to start with a file as the first message - Add --model flag: launch-time model override @@ -80,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support reading PDF and image files as native API content blocks - Survive a mid-turn network drop: keep the machine awake during a request, persist the conversation as each message is sent and answered, and resume an interrupted turn from an empty submit - Tell the model the working directory: state it up front, and report the from/to when it changes mid-session +- The session watches its conversation's attachment leaf and stands down when another instance attaches: it stops answering wire requests, stops committing changes, publishes detached, and tells the user - Track session history per working directory for future session picker - Write BetaMessage per turn to ~/.claude/audit/.jsonl @@ -89,9 +91,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A broken dependency wiring now fails the build or startup - Add a plain-ASCII fast path to the TUI cell-grid layout, skipping Intl.Segmenter and stringWidth for rows with no ANSI styling and no wide or combining characters, cutting per-frame layout cost for plain-text rows - Adopt core-di-lite property injection end to end: the container resolves the whole graph eagerly, SQLite databases are created through a registered factory, and CLI startup moves into main() so the entry module's only import-time effect is invoking it +- Agent requests answer with the current reply vocabulary: invalid (with detail) for a missing conversationId or cwd, and replies may carry a detail field +- Attachment claims now publish on the conversation's own tree (conv.v2.{id}.attachment.attached/moved/detached) carrying the world/instanceId identity pair, cwd, tip, and pulse interval; a directory change publishes moved instead of a second attached, and nothing publishes on the old agent.v1 attachment subjects - Az account config: reader/holder identities are now configured with a type (cert or interactive) and optional subscriptionIds, replacing readerClientId/holderClientId - AzCli, EscalatedAzCli, and AzureDevOps.PullRequest.* are hidden from a turn's tools whenever no matching account is configured - Block header dividers now pad to a fixed minimum width instead of the full terminal width, so the trailing run of hyphens no longer scales with the window while short headers still line up +- Change events on the wire now carry the publishing instance id as envelope provenance, and a tool_result message no longer fabricates a from sender - claude-cli now records each session's directory to a central store and resumes the most-recent session for the current directory, so a conversation survives a restart or a machine going away - Command mode can now be entered, navigated, and exited while a query is streaming, not only in the editor phase - Config system tracks which file each value came from diff --git a/apps/claude-sdk-cli/changes.jsonl b/apps/claude-sdk-cli/changes.jsonl index d8ab64a5..4bf029e5 100644 --- a/apps/claude-sdk-cli/changes.jsonl +++ b/apps/claude-sdk-cli/changes.jsonl @@ -166,3 +166,8 @@ {"description":"ExecV3 now overrides AZURE_CONFIG_DIR and strips ambient Azure credential env vars, so a model-driven az command can never inherit a real ambient session or SDK credential","category":"security"} {"description":"Az account config: reader/holder identities are now configured with a type (cert or interactive) and optional subscriptionIds, replacing readerClientId/holderClientId","category":"changed"} {"description":"A notice now prints in the conversation whenever a tool's disabled/enabled state actually flips on a config reload (e.g. AzCli/EscalatedAzCli becoming available as an account is configured)","category":"added"} +{"description":"Attachment claims now publish on the conversation's own tree (conv.v2.{id}.attachment.attached/moved/detached) carrying the world/instanceId identity pair, cwd, tip, and pulse interval; a directory change publishes moved instead of a second attached, and nothing publishes on the old agent.v1 attachment subjects","category":"changed"} +{"description":"The session watches its conversation's attachment leaf and stands down when another instance attaches: it stops answering wire requests, stops committing changes, publishes detached, and tells the user","category":"added"} +{"description":"A say over the wire can carry object-store attachments (images, PDFs): each block resolves from the bucket it names, the bytes are inlined for the model, the committed message keeps the reference block, and a say whose attachment does not resolve is rejected attachment_unavailable","category":"added"} +{"description":"Change events on the wire now carry the publishing instance id as envelope provenance, and a tool_result message no longer fabricates a from sender","category":"changed"} +{"description":"Agent requests answer with the current reply vocabulary: invalid (with detail) for a missing conversationId or cwd, and replies may carry a detail field","category":"changed"} diff --git a/apps/claude-sdk-cli/package.json b/apps/claude-sdk-cli/package.json index 5ce63f4c..f50c4283 100644 --- a/apps/claude-sdk-cli/package.json +++ b/apps/claude-sdk-cli/package.json @@ -58,6 +58,7 @@ "@anthropic-ai/sdk": "^0.92.0", "@js-joda/core": "^5.7.0", "@nats-io/nats-core": "^3.4.0", + "@nats-io/obj": "^3.4.0", "@nats-io/transport-node": "^3.4.0", "@shellicar/claude-core": "workspace:^", "@shellicar/claude-sdk": "workspace:^", diff --git a/apps/claude-sdk-cli/spec/agent.v1.zod.ts b/apps/claude-sdk-cli/spec/agent.v1.zod.ts index 9cf98156..fcab5981 100644 --- a/apps/claude-sdk-cli/spec/agent.v1.zod.ts +++ b/apps/claude-sdk-cli/spec/agent.v1.zod.ts @@ -20,12 +20,11 @@ const sender = z.looseObject({ // the id); ephemeral reach-handles (pid, port, tmux coords) are not named — // they ride as open fields under looseObject (nats-spec, Naming). -// agent.v1.{world}.telemetry.> +// agent.v1.{world}.telemetry.> — attachment claims are not here; their +// schema lives on the conversation's own tree (conversation-spec.md, Attachment). export const agentTelemetry = { ready: z.looseObject({ ts, instanceId: z.string(), host: z.string().optional() }), pulse: z.looseObject({ ts, instanceId: z.string(), intervalS: z.number().int().positive() }), - attached: z.looseObject({ ts, instanceId: z.string(), conversationId: z.string(), cwd: z.string().optional() }), - detached: z.looseObject({ ts, instanceId: z.string(), conversationId: z.string() }), } as const; // agent.v1.{world}.requests.> — a leaf not listed is still answered: @@ -37,5 +36,8 @@ export const agentRequest = { } as const; // Replies (transport truth, never outcome). Known reasons today: -// already_attached, at_capacity, not_found, unsupported. -export const agentRequestReply = z.union([z.looseObject({ accepted: z.literal(true) }), z.looseObject({ rejected: z.literal(true), reason: z.string() })]); +// already_attached, at_capacity, invalid, invalid_cwd, not_found, failed, +// unsupported. `detail` is optional free-text diagnostics for a human — +// `reason` is the machine-facing token a caller branches on, `detail` names +// the step and underlying error; never the other way around. +export const agentRequestReply = z.union([z.looseObject({ accepted: z.literal(true) }), z.looseObject({ rejected: z.literal(true), reason: z.string(), detail: z.string().optional() })]); diff --git a/apps/claude-sdk-cli/spec/conv.v1.zod.ts b/apps/claude-sdk-cli/spec/conv.v1.zod.ts index fdf9a0ef..0c93adae 100644 --- a/apps/claude-sdk-cli/spec/conv.v1.zod.ts +++ b/apps/claude-sdk-cli/spec/conv.v1.zod.ts @@ -55,20 +55,39 @@ export const conversationTelemetry = z.discriminatedUnion('type', [ ]); // conv.v1.{conversationId}.changes +// v1 differs from v2 in shape, not vocabulary (conversation-spec, The v1 tree): message shapes are +// identical to v2 — `from` absent on a tool_result delivery, `instanceId` as envelope provenance. export const conversationChange = z.discriminatedUnion('type', [ - z.looseObject({ type: z.literal('message'), ts, id: z.string(), ...turnRef, role: openEnum(['user', 'assistant']), from: sender, content: contentBlocks }), - z.looseObject({ type: z.literal('revision'), ts, messageId: z.string(), content: contentBlocks }), - z.looseObject({ type: z.literal('tip_moved'), ts, to: z.string() }), + z.looseObject({ type: z.literal('message'), ts, instanceId: z.string().optional(), id: z.string(), ...turnRef, role: openEnum(['user', 'assistant']), from: sender.optional(), content: contentBlocks }), + z.looseObject({ type: z.literal('revision'), ts, instanceId: z.string().optional(), messageId: z.string(), content: contentBlocks }), + z.looseObject({ type: z.literal('tip_moved'), ts, instanceId: z.string().optional(), to: z.string() }), ]); // conv.v1.{conversationId}.deltas — deliberately bare: the envelope's `ts` is // waived on purpose; deltas are ephemeral and the metadata would outweigh the data. -export const conversationDelta = z.looseObject({ type: z.literal('delta'), text: z.string() }); +export const conversationDelta = z.discriminatedUnion('type', [z.looseObject({ type: z.literal('delta'), text: z.string() }), z.looseObject({ type: z.literal('block'), blockType: openEnum(['thinking', 'text', 'tool_use']) })]); // conv.v1.{conversationId}.requests — a request whose `type` is not defined // here is still answered: `rejected` with reason `unsupported`. Compliance is // answering, not implementing. -export const conversationRequest = z.discriminatedUnion('type', [z.looseObject({ type: z.literal('say'), ts, from: sender, text: z.string(), precondition: z.looseObject({ tip: z.string() }).optional() }), z.looseObject({ type: z.literal('cancel'), ts, from: sender.optional(), id: z.string() })]); +export const conversationRequest = z.discriminatedUnion('type', [ + z.looseObject({ + type: z.literal('say'), + ts, + from: sender, + text: z.string(), + attachments: z + .array( + z.looseObject({ + type: z.string(), + source: z.looseObject({ type: z.string(), id: z.string(), mediaType: z.string().optional(), size: z.number().int().optional() }), + }), + ) + .optional(), + precondition: z.looseObject({ tip: z.string().nullable() }), + }), + z.looseObject({ type: z.literal('cancel'), ts, from: sender.optional(), id: z.string() }), +]); // Replies (transport truth, never outcome). Known reasons today: // stale, not_found, already_complete, unsupported. diff --git a/apps/claude-sdk-cli/spec/conv.v2.zod.ts b/apps/claude-sdk-cli/spec/conv.v2.zod.ts index c733d31d..8d2db4ec 100644 --- a/apps/claude-sdk-cli/spec/conv.v2.zod.ts +++ b/apps/claude-sdk-cli/spec/conv.v2.zod.ts @@ -56,12 +56,25 @@ export const conversationTelemetry = { }), } as const; -// conv.v2.{conversationId}.changes.> +// conv.v2.{conversationId}.changes.> — instanceId is envelope metadata +// (beside from, never inside it): which agent instance published the change. export const conversationChange = { - message: z.looseObject({ ts, id: z.string(), ...turnRef, role: openEnum(['user', 'assistant']), from: sender, content: contentBlocks }), - revision: z.looseObject({ ts, messageId: z.string(), content: contentBlocks }), - 'tip.moved': z.looseObject({ ts, to: z.string() }), - query: z.looseObject({ ts, queryId: z.string(), reason: openEnum(['completed', 'cancelled', 'aborted']) }), + message: z.looseObject({ ts, instanceId: z.string().optional(), id: z.string(), ...turnRef, role: openEnum(['user', 'assistant']), from: sender.optional(), content: contentBlocks }), + revision: z.looseObject({ ts, instanceId: z.string().optional(), messageId: z.string(), content: contentBlocks }), + 'tip.moved': z.looseObject({ ts, instanceId: z.string().optional(), to: z.string() }), + query: z.looseObject({ ts, instanceId: z.string().optional(), queryId: z.string(), reason: openEnum(['completed', 'cancelled', 'aborted']) }), +} as const; + +// conv.v2.{conversationId}.attachment.> — the wire shape of the model +// agent-spec.md conducts (singular, unconditionally superseding). world is +// provenance, never address, exactly like instanceId — but together they +// are the instance identity (agent-spec.md, The entity), so world is +// required of every compliant publisher; optional here only for producers +// that predate this rule. +export const conversationAttachment = { + attached: z.looseObject({ ts, instanceId: z.string(), world: z.string().optional(), cwd: z.string().optional(), tip: z.string().nullable().optional(), intervalS: z.number().int().positive().optional() }), + moved: z.looseObject({ ts, instanceId: z.string(), world: z.string().optional(), cwd: z.string() }), + detached: z.looseObject({ ts, instanceId: z.string(), world: z.string().optional() }), } as const; // conv.v2.{conversationId}.deltas — the one flat subject: `delta` and `block` diff --git a/apps/claude-sdk-cli/spec/generate-schemas.ts b/apps/claude-sdk-cli/spec/generate-schemas.ts index 3a35aa62..06852e72 100644 --- a/apps/claude-sdk-cli/spec/generate-schemas.ts +++ b/apps/claude-sdk-cli/spec/generate-schemas.ts @@ -13,7 +13,7 @@ import { z } from 'zod'; import { agentRequest, agentRequestReply, agentTelemetry } from './agent.v1.zod.js'; import { answerReply, approvalLifecycle, approvalRequest, approvalTelemetry } from './approval.v1.zod.js'; import { conversationChange, conversationDelta, conversationRequest, conversationTelemetry, requestReply } from './conv.v1.zod.js'; -import { conversationChange as conversationChangeV2, conversationDelta as conversationDeltaV2, conversationRequest as conversationRequestV2, conversationTelemetry as conversationTelemetryV2, requestReply as requestReplyV2 } from './conv.v2.zod.js'; +import { conversationAttachment as conversationAttachmentV2, conversationChange as conversationChangeV2, conversationDelta as conversationDeltaV2, conversationRequest as conversationRequestV2, conversationTelemetry as conversationTelemetryV2, requestReply as requestReplyV2 } from './conv.v2.zod.js'; const schemas = { 'conv.telemetry': conversationTelemetry, @@ -35,6 +35,7 @@ const schemas = { const leafedSchemas = { 'conv.v2.telemetry': conversationTelemetryV2, 'conv.v2.changes': conversationChangeV2, + 'conv.v2.attachment': conversationAttachmentV2, 'conv.v2.requests': conversationRequestV2, 'agent.telemetry': agentTelemetry, 'agent.requests': agentRequest, diff --git a/apps/claude-sdk-cli/src/agent/AgentPresence.ts b/apps/claude-sdk-cli/src/agent/AgentPresence.ts index 3a2fc321..e3d3e6de 100644 --- a/apps/claude-sdk-cli/src/agent/AgentPresence.ts +++ b/apps/claude-sdk-cli/src/agent/AgentPresence.ts @@ -7,28 +7,37 @@ import { IBus } from '../bus/IBus.js'; import type { sdkConfigSchema } from '../cli-config/schema.js'; import { stamp } from '../conv/wire.js'; -/** The presence contract; register abstract\u2192concrete and depend on the abstract (DI rule). */ +/** The presence contract; register abstract→concrete and depend on the abstract (DI rule). */ export abstract class IAgentPresence { public abstract readonly instanceId: string; public abstract readonly world: string; /** Publish `ready` and start pulsing. Call once, after subscriptions are up (agent-spec). */ public abstract boot(): void; - /** This instance now serves `conversationId` at `cwd`. Re-publish on a `cwd` move (last-write-wins). - * Carries `intervalS` — additive, forwards-compatible — so a consumer who never sees a pulse (a late - * joiner between pulses, a deployment that captures attachment but not liveness) still knows the - * liveness promise being advertised, rather than only learning it from the first heartbeat. */ - public abstract attach(conversationId: string, cwd: string): void; - /** Released, deliberately \u2014 Ctrl-C, drain, done. A crash publishes nothing (agent-spec). */ + /** Open this instance's claim on `conversationId`: publish `attached` on the conversation's own + * attachment leaf, exactly once per claim (agent-spec, Attachment) — a re-call while the claim is + * open is a no-op, never a second `attached`. Carries the identity pair (world, instanceId), `cwd`, + * the liveness promise `intervalS`, and the conversation's `tip` so an observer knows where the + * conversation stands without replaying the change stream. */ + public abstract attach(conversationId: string, cwd: string, tip: string | null): void; + /** The working directory changed under the open claim: publish `moved` — a fact about the standing + * claim, never a second `attached` (conversation-spec, Attachment). No open claim: no-op. */ + public abstract move(conversationId: string, cwd: string): void; + /** Released, deliberately — Ctrl-C, drain, done, or standing down after displacement. Publishes + * `detached` and closes the claim; a crash publishes nothing (agent-spec). */ public abstract detach(conversationId: string): void; - /** Stop pulsing \u2014 called once on clean shutdown, after every conversation has detached. */ + /** Whether this instance currently holds an open claim on `conversationId` — the gate change + * publishers consult so a displaced instance stops committing (agent-spec, Attachment). */ + public abstract hasClaim(conversationId: string): boolean; + /** Stop pulsing — called once on clean shutdown, after every conversation has detached. */ public abstract stop(): void; } /** - * The agent concern's telemetry face: `ready` once on boot, a `pulse` liveness promise on an interval, - * and `attached`/`detached` around this instance's conversation binding. `instanceId` is minted fresh - * per process (agent-spec: an instance's lifetime is its own, and a restarted bridge is a new instance in - * the same world). Zero effect when the bus is disabled (IBus.publish is a no-op then). + * The agent's presence on the wire: `ready` once on boot and a `pulse` liveness promise on an interval, + * both on the world's own telemetry tree, plus the conversation attachment claim — `attached`/`moved`/ + * `detached` on `conv.v2.{id}.attachment.>`, the conversation's own tree (conversation-spec, Attachment). + * `instanceId` is minted fresh per process (agent-spec: a restarted process is a new instance in the + * same world). Zero effect when the bus is disabled (IBus.publish is a no-op then). */ export class AgentPresence extends IAgentPresence { @dependsOn(IBus) private readonly bus!: IBus; @@ -36,6 +45,7 @@ export class AgentPresence extends IAgentPresence { @dependsOn(ConfigLoader) private readonly configLoader!: ConfigLoader; public readonly instanceId = randomUUID(); #pulse: NodeJS.Timeout | null = null; + readonly #claims = new Set(); public get world(): string { return this.configLoader.config.nats.world; @@ -50,12 +60,30 @@ export class AgentPresence extends IAgentPresence { this.#pulse.unref(); } - public attach(conversationId: string, cwd: string): void { - this.bus.publish(`agent.v1.${this.world}.telemetry.attached`, stamp(this.clock, { instanceId: this.instanceId, conversationId, cwd, intervalS: this.configLoader.config.nats.pulseIntervalS })); + public attach(conversationId: string, cwd: string, tip: string | null): void { + if (this.#claims.has(conversationId)) { + return; // exactly once per open claim — a second attached is the violation shape (agent-spec) + } + this.#claims.add(conversationId); + this.bus.publish(`conv.v2.${conversationId}.attachment.attached`, stamp(this.clock, { instanceId: this.instanceId, world: this.world, cwd, tip, intervalS: this.configLoader.config.nats.pulseIntervalS })); + } + + public move(conversationId: string, cwd: string): void { + if (!this.#claims.has(conversationId)) { + return; + } + this.bus.publish(`conv.v2.${conversationId}.attachment.moved`, stamp(this.clock, { instanceId: this.instanceId, world: this.world, cwd })); } public detach(conversationId: string): void { - this.bus.publish(`agent.v1.${this.world}.telemetry.detached`, stamp(this.clock, { instanceId: this.instanceId, conversationId })); + if (!this.#claims.delete(conversationId)) { + return; + } + this.bus.publish(`conv.v2.${conversationId}.attachment.detached`, stamp(this.clock, { instanceId: this.instanceId, world: this.world })); + } + + public hasClaim(conversationId: string): boolean { + return this.#claims.has(conversationId); } public stop(): void { diff --git a/apps/claude-sdk-cli/src/agent/AgentServicer.ts b/apps/claude-sdk-cli/src/agent/AgentServicer.ts index 12380d95..a97b9bda 100644 --- a/apps/claude-sdk-cli/src/agent/AgentServicer.ts +++ b/apps/claude-sdk-cli/src/agent/AgentServicer.ts @@ -39,10 +39,14 @@ export class AgentServicer extends IAgentServicer { try { req = JSON.parse(new TextDecoder().decode(payload)); } catch { - return encode({ rejected: true, reason: 'unsupported' }); + // A recognised leaf whose body cannot be read carries nothing it needs (agent-spec). + return encode({ rejected: true, reason: 'invalid', detail: 'body is not valid JSON' }); } if (leaf === 'service') { + if (req.conversationId == null || req.conversationId === '') { + return encode({ rejected: true, reason: 'invalid', detail: 'conversationId is missing or empty' }); + } if (req.conversationId === this.session.id) { return encode({ rejected: true, reason: 'already_attached' }); } @@ -56,11 +60,15 @@ export class AgentServicer extends IAgentServicer { } if (leaf === 'chdir') { - if (req.conversationId !== this.session.id || req.cwd == null) { + if (req.conversationId == null || req.conversationId === '' || req.cwd == null || req.cwd === '') { + return encode({ rejected: true, reason: 'invalid', detail: 'conversationId and cwd are required' }); + } + if (req.conversationId !== this.session.id) { return encode({ rejected: true, reason: 'not_found' }); } - // Accept confirms the premise, never the outcome: the move is observed via a re-published - // `attached` when it lands (agent-spec) \u2014 WorkingDirectory's `change` event drives that re-publish. + // Accept confirms the premise (this world serves the conversation), never the outcome: the move + // is observed via `attachment.moved` when it lands (agent-spec) — WorkingDirectory's `change` + // event drives that publish, and a move that never lands just shows as an unchanged cwd. this.workingDirectory.change(req.cwd); return encode({ accepted: true }); } diff --git a/apps/claude-sdk-cli/src/agent/AttachmentGuard.ts b/apps/claude-sdk-cli/src/agent/AttachmentGuard.ts new file mode 100644 index 00000000..53098237 --- /dev/null +++ b/apps/claude-sdk-cli/src/agent/AttachmentGuard.ts @@ -0,0 +1,59 @@ +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { dependsOn } from '@shellicar/core-di'; +import { IBus } from '../bus/IBus.js'; +import { IConvServe } from '../conv/ConvServe.js'; +import { IConversationState } from '../model/ConversationState.js'; +import { IAgentPresence } from './AgentPresence.js'; + +/** The guard's contract; register abstract→concrete and depend on the abstract (DI rule). */ +export abstract class IAttachmentGuard { + /** Watch `conversationId`'s attachment leaf for displacement. Re-pointed on `/new`, like the serve + * binding: the previous watch is disposed and the new conversation's leaf watched instead. */ + public abstract watch(conversationId: string): void; +} + +/** + * A compliant instance watches the attachment leaf of every conversation it serves (agent-spec, + * Attachment). Another identity's `attached` means this instance is superseded: it stops serving — + * the wire serve binding is dropped and the open claim closed, which gates further change publishes — + * publishes `detached` as the observable act of standing down, and tells the user honestly. Identity + * is the `(world, instanceId)` pair, falling back to bare `instanceId` when either side omits `world`. + */ +export class AttachmentGuard extends IAttachmentGuard { + @dependsOn(IBus) private readonly bus!: IBus; + @dependsOn(IAgentPresence) private readonly presence!: IAgentPresence; + @dependsOn(IConvServe) private readonly convServe!: IConvServe; + @dependsOn(IConversationState) private readonly conversationState!: IConversationState; + @dependsOn(ILogger) private readonly logger!: ILogger; + #dispose: (() => void) | null = null; + + public watch(conversationId: string): void { + this.#dispose?.(); + this.#dispose = this.bus.subscribe(`conv.v2.${conversationId}.attachment.attached`, (_subject, payload) => this.#onAttached(conversationId, payload)); + } + + #onAttached(conversationId: string, payload: Uint8Array): void { + let claim: { instanceId?: string; world?: string }; + try { + claim = JSON.parse(new TextDecoder().decode(payload)); + } catch { + return; // not a claim we can read — tolerance, never an error + } + if (claim.instanceId == null || !this.presence.hasClaim(conversationId)) { + return; + } + const sameInstance = claim.instanceId === this.presence.instanceId; + const sameWorld = claim.world == null || claim.world === this.presence.world; + if (sameInstance && sameWorld) { + return; // our own claim echoed back + } + // Superseded: stop serving, stand down observably, surface it (agent-spec, Attachment). + this.#dispose?.(); + this.#dispose = null; + this.convServe.unbind(); + this.presence.detach(conversationId); + this.logger.info('attachment superseded', { conversationId, by: claim.instanceId, world: claim.world }); + this.conversationState.completeActive(); + this.conversationState.spliceNotice(`⚠️ This conversation is now served by another instance (${claim.world ?? 'unknown world'}). This session has stood down and no longer commits to it.`); + } +} diff --git a/apps/claude-sdk-cli/src/bus/IBus.ts b/apps/claude-sdk-cli/src/bus/IBus.ts index 123c8f50..8fde4882 100644 --- a/apps/claude-sdk-cli/src/bus/IBus.ts +++ b/apps/claude-sdk-cli/src/bus/IBus.ts @@ -4,7 +4,7 @@ export type BusReply = { data: Uint8Array } | { timeout: true } | { noResponders /** A `serve` handler: given the request bytes and the subject it arrived on (a v2 leaf subject may serve * a wildcard, so the handler routes on the subject it actually received), return the reply bytes. */ -export type ServeHandler = (payload: Uint8Array, subject: string) => Uint8Array; +export type ServeHandler = (payload: Uint8Array, subject: string) => Uint8Array | Promise; /** * One NATS connection behind four faces. `ITapTransport` (publish only) widened to the request/reply the @@ -17,5 +17,9 @@ export abstract class IBus { public abstract subscribe(subject: string, handler: (subject: string, payload: Uint8Array) => void): () => void; public abstract request(subject: string, payload: Uint8Array, timeoutMs: number): Promise; public abstract serve(subject: string, handler: ServeHandler): () => void; + /** Fetch one object's bytes from the named transit bucket — the bucket the reference block itself + * names, never ambient config (conversation-spec, say attachments). Returns null when the bus is + * disabled or the bucket/object does not resolve; the caller decides what a failure means. */ + public abstract fetchObject(bucket: string, id: string): Promise; public abstract stop(): Promise; } diff --git a/apps/claude-sdk-cli/src/bus/NatsBus.ts b/apps/claude-sdk-cli/src/bus/NatsBus.ts index 2621a7fb..dffbb317 100644 --- a/apps/claude-sdk-cli/src/bus/NatsBus.ts +++ b/apps/claude-sdk-cli/src/bus/NatsBus.ts @@ -80,7 +80,18 @@ export class NatsBus extends IBus { return; // no reply subject means nothing to answer; a delivery error is dropped, never thrown } try { - msg.respond(handler(msg.data, msg.subject)); + const reply = handler(msg.data, msg.subject); + if (reply instanceof Uint8Array) { + msg.respond(reply); + } else { + reply.then( + (bytes) => msg.respond(bytes), + (e) => { + this.logger.warn('serve handler rejected', { subject, error: String(e) }); + msg.respond(new TextEncoder().encode(JSON.stringify({ rejected: true, reason: 'internal_error' }))); + }, + ); + } } catch (e) { // A throwing handler must still reply, or the requester waits out its full timeout. Reply with an // error marker so the caller fails fast. Inert today — both live handlers catch internally and @@ -93,6 +104,22 @@ export class NatsBus extends IBus { return () => sub.unsubscribe(); } + public async fetchObject(bucket: string, id: string): Promise { + if (this.#nc == null) { + return null; + } + try { + const { Objm } = await import('@nats-io/obj'); + const store = await new Objm(this.#nc).open(bucket); + return await store.getBlob(id); + } catch (err) { + // A missing bucket or object and a transport failure land the same way for a fresh say: the + // object the sender just referenced is not resolvable (conversation-spec, say attachments). + this.logger.warn('object fetch failed', { bucket, id, error: String(err) }); + return null; + } + } + public async stop(): Promise { await this.#nc?.drain(); this.#nc = null; diff --git a/apps/claude-sdk-cli/src/controller/CommandIntentExecutor.ts b/apps/claude-sdk-cli/src/controller/CommandIntentExecutor.ts index 16136122..5768e1ce 100644 --- a/apps/claude-sdk-cli/src/controller/CommandIntentExecutor.ts +++ b/apps/claude-sdk-cli/src/controller/CommandIntentExecutor.ts @@ -7,6 +7,7 @@ import { CacheTtl, IModelCatalog } from '@shellicar/claude-sdk'; import { dependsOn } from '@shellicar/core-di'; import { AuditStats } from '../AuditStats.js'; import { IAgentPresence } from '../agent/AgentPresence.js'; +import { IAttachmentGuard } from '../agent/AttachmentGuard.js'; import { detectMediaType } from '../clipboard.js'; import { IConvServe } from '../conv/ConvServe.js'; import { AttachmentSource } from '../model/AttachmentSource.js'; @@ -51,6 +52,7 @@ export class CommandIntentExecutor { @dependsOn(AuditStats) private readonly auditStats!: AuditStats; @dependsOn(IConvServe) private readonly convServe!: IConvServe; @dependsOn(IAgentPresence) private readonly agentPresence!: IAgentPresence; + @dependsOn(IAttachmentGuard) private readonly attachmentGuard!: IAttachmentGuard; @dependsOn(IFileSystem) private readonly fs!: IFileSystem; @dependsOn(IWorkingDirectory) private readonly workingDirectory!: IWorkingDirectory; @dependsOn(IModelCatalog) private readonly modelCatalog!: IModelCatalog; @@ -76,9 +78,10 @@ export class CommandIntentExecutor { // A run is process + conversation, so a switch moves the addressable subject: re-point the wire // serve to the new conversation so it is reachable over NATS immediately, not only after relaunch. this.convServe.bind(this.session.id); - // The attachment moves with it — detach the old conversation, attach the new one (agent-spec). + // The attachment moves with it — detach the old conversation, claim the new one (agent-spec). this.agentPresence.detach(previousId); - this.agentPresence.attach(this.session.id, this.fs.cwd()); + this.attachmentGuard.watch(this.session.id); + this.agentPresence.attach(this.session.id, this.fs.cwd(), null); this.systemIdentity.inherit(this.session.id); this.conversationState.clear(); // Re-derive the status figures for the fresh id. A brand-new id has no diff --git a/apps/claude-sdk-cli/src/conv/ConvChangePublisher.ts b/apps/claude-sdk-cli/src/conv/ConvChangePublisher.ts index 91b08b79..b93160db 100644 --- a/apps/claude-sdk-cli/src/conv/ConvChangePublisher.ts +++ b/apps/claude-sdk-cli/src/conv/ConvChangePublisher.ts @@ -1,7 +1,9 @@ import { Clock } from '@js-joda/core'; import { IConversation } from '@shellicar/claude-sdk'; import { dependsOn } from '@shellicar/core-di'; +import { IAgentPresence } from '../agent/AgentPresence.js'; import { IBus } from '../bus/IBus.js'; +import { IWireAttachmentLedger } from './WireAttachmentLedger.js'; import { stamp } from './wire.js'; /** `query` closure reasons — an open set under add-only; these are the ones defined today @@ -28,11 +30,16 @@ export class ConvChangePublisher extends IConvChangePublisher { @dependsOn(IConversation) private readonly conversation!: IConversation; @dependsOn(IBus) private readonly bus!: IBus; @dependsOn(Clock) private readonly clock!: Clock; + @dependsOn(IAgentPresence) private readonly presence!: IAgentPresence; + @dependsOn(IWireAttachmentLedger) private readonly ledger!: IWireAttachmentLedger; #published = 0; #lastClosedQueryId: string | null = null; /** Publish `message` changes for newly-committed rows. Called after each saveConversation. */ public flush(conversationId: string): void { + if (!this.presence.hasClaim(conversationId)) { + return; // superseded: a displaced instance stops committing (agent-spec, Attachment) + } const items = this.conversation.items; for (let i = this.#published; i < items.length; i++) { const item = items[i]; @@ -40,8 +47,18 @@ export class ConvChangePublisher extends IConvChangePublisher { if (id == null) { continue; // a legacy row carries no identity — nothing to key a change on } - const content = Array.isArray(item.msg.content) ? item.msg.content : [{ type: 'text', text: item.msg.content }]; - this.bus.publish(`conv.v2.${conversationId}.changes.message`, stamp(this.clock, { id: id.messageId, queryId: id.queryId, turnId: id.turnId, role: item.msg.role, from: id.from, content })); + let content: readonly unknown[] = Array.isArray(item.msg.content) ? item.msg.content : [{ type: 'text', text: item.msg.content }]; + // A wire say's attachments commit as the reference blocks verbatim, never the inlined bytes + // (conversation-spec, say attachments): swap the base64 blocks back for the blocks that rode the say. + const refs = item.msg.role === 'user' ? this.ledger.take(id.queryId) : null; + if (refs != null) { + content = [...refs, ...content.filter((block) => (block as { type?: string }).type === 'text')]; + } + // A tool_result delivery is mechanical, not an utterance: nobody sent it, so `from` is absent + // rather than fabricated (conversation-spec, correction 19 Jul 2026). + const from = content.some((block) => (block as { type?: string }).type === 'tool_result') ? undefined : id.from; + // instanceId is envelope provenance, beside `from`, never inside it (conversation-spec). + this.bus.publish(`conv.v2.${conversationId}.changes.message`, stamp(this.clock, { instanceId: this.presence.instanceId, id: id.messageId, queryId: id.queryId, turnId: id.turnId, role: item.msg.role, from, content })); } this.#published = items.length; } @@ -58,6 +75,9 @@ export class ConvChangePublisher extends IConvChangePublisher { return; } this.#lastClosedQueryId = queryId; - this.bus.publish(`conv.v2.${conversationId}.changes.query`, stamp(this.clock, { queryId, reason })); + if (!this.presence.hasClaim(conversationId)) { + return; // superseded: a displaced instance stops committing (agent-spec, Attachment) + } + this.bus.publish(`conv.v2.${conversationId}.changes.query`, stamp(this.clock, { instanceId: this.presence.instanceId, queryId, reason })); } } diff --git a/apps/claude-sdk-cli/src/conv/ConvServe.ts b/apps/claude-sdk-cli/src/conv/ConvServe.ts index 6c8a2349..b79a29c2 100644 --- a/apps/claude-sdk-cli/src/conv/ConvServe.ts +++ b/apps/claude-sdk-cli/src/conv/ConvServe.ts @@ -12,6 +12,8 @@ import { IConvServicer } from './ConvServicer.js'; */ export abstract class IConvServe { public abstract bind(conversationId: string): void; + /** Drop the serve binding without replacing it — a displaced instance stops answering (agent-spec). */ + public abstract unbind(): void; } export class ConvServe extends IConvServe { @@ -23,4 +25,9 @@ export class ConvServe extends IConvServe { this.#dispose?.(); this.#dispose = this.bus.serve(`conv.v2.${conversationId}.requests.*`, (payload, subject) => this.servicer.handle(payload, subject)); } + + public unbind(): void { + this.#dispose?.(); + this.#dispose = null; + } } diff --git a/apps/claude-sdk-cli/src/conv/ConvServicer.ts b/apps/claude-sdk-cli/src/conv/ConvServicer.ts index df9285a3..5efa29ee 100644 --- a/apps/claude-sdk-cli/src/conv/ConvServicer.ts +++ b/apps/claude-sdk-cli/src/conv/ConvServicer.ts @@ -2,26 +2,45 @@ import { randomUUID } from 'node:crypto'; import { ILogger } from '@shellicar/claude-core/logging/ILogger'; import { IConversation, type Sender } from '@shellicar/claude-sdk'; import { dependsOn } from '@shellicar/core-di'; +import { IBus } from '../bus/IBus.js'; import { ConsumerChannel } from '../setup/ConsumerChannel.js'; -import { IWireSayInbox } from './WireSayInbox.js'; +import type { AttachmentReferenceBlock } from './WireAttachmentLedger.js'; +import { IWireAttachmentLedger } from './WireAttachmentLedger.js'; +import { IWireSayInbox, type ResolvedAttachment } from './WireSayInbox.js'; import { encode } from './wire.js'; /** The addressable face's contract; register abstract→concrete and depend on the abstract (DI rule). */ export abstract class IConvServicer { public abstract setBusy(busy: boolean): void; - public abstract handle(payload: Uint8Array, subject: string): Uint8Array; + public abstract handle(payload: Uint8Array, subject: string): Uint8Array | Promise; } +type SayRequest = { + text?: string; + id?: string; + from?: Sender; + precondition?: { tip?: string | null }; + attachments?: AttachmentReferenceBlock[]; +}; + /** * The addressable face of the conversation, serving `conv.v2.{id}.requests.*`. v2 routes by subject leaf * (the token after `requests.`), never a body `type` — `say` and `cancel` are the two defined leaves. A * `say` is checked against the premise then delivered to the inbox with a minted queryId; `cancel` routes * to the existing cancel path; an unknown leaf is answered `rejected: unsupported` — compliance is * answering, not implementing. + * + * A say's attachments resolve here, at the servicer's edge, before acceptance: each `object`-source + * block is fetched from the bucket the block itself names — never ambient config, never a fallback + * (conversation-spec, say attachments). A fresh block that does not resolve is never ageing: the say + * rejects `attachment_unavailable` rather than hand the model a placeholder for what the sender just + * attached. */ export class ConvServicer extends IConvServicer { @dependsOn(IConversation) private readonly conversation!: IConversation; @dependsOn(IWireSayInbox) private readonly inbox!: IWireSayInbox; + @dependsOn(IWireAttachmentLedger) private readonly ledger!: IWireAttachmentLedger; + @dependsOn(IBus) private readonly bus!: IBus; @dependsOn(ConsumerChannel) private readonly channel!: ConsumerChannel; @dependsOn(ILogger) private readonly logger!: ILogger; @@ -34,9 +53,9 @@ export class ConvServicer extends IConvServicer { } /** The bus serve handler body: parse the request, route by subject leaf, return the reply bytes. */ - public handle(payload: Uint8Array, subject: string): Uint8Array { + public handle(payload: Uint8Array, subject: string): Uint8Array | Promise { const leaf = subject.split('.').at(-1); - let req: { text?: string; id?: string; from?: Sender; precondition?: { tip?: string | null } }; + let req: SayRequest; try { req = JSON.parse(new TextDecoder().decode(payload)); } catch { @@ -44,23 +63,14 @@ export class ConvServicer extends IConvServicer { } if (leaf === 'say') { - const tip = this.conversation.items.at(-1)?.identity?.messageId ?? null; - // A stated premise that does not match the tip is stale. The premise is required (conversation-spec): - // a fresh conversation's first say states `{ tip: null }` rather than omitting it. - const statedTip = req.precondition?.tip ?? null; - if (statedTip !== tip) { - return encode({ rejected: true, reason: 'stale' }); + const premiseFailure = this.#checkSayPremise(req); + if (premiseFailure != null) { + return premiseFailure; } - if (this.#busy) { - // A turn is live: the premise has a live acceptance. cancel-then-send is the affordance. - return encode({ rejected: true, reason: 'stale' }); + if (req.attachments == null || req.attachments.length === 0) { + return this.#acceptSay(req, []); } - this.#busy = true; // close the gap before runTurn sets it - const queryId = randomUUID(); - const from: Sender = req.from ?? { kind: 'human' }; - this.logger.info('say accepted', { queryId }); - this.inbox.deliver({ text: req.text ?? '', queryId, from }); - return encode({ accepted: true, id: queryId }); + return this.#resolveThenAcceptSay(req); } if (leaf === 'cancel') { @@ -80,4 +90,66 @@ export class ConvServicer extends IConvServicer { // revise, history, wire-visible rewind, unknown — answered honestly, not implemented. return encode({ rejected: true, reason: 'unsupported' }); } + + /** null when the premise holds and no acceptance is live; otherwise the rejection bytes. */ + #checkSayPremise(req: SayRequest): Uint8Array | null { + const tip = this.conversation.items.at(-1)?.identity?.messageId ?? null; + // A stated premise that does not match the tip is stale. The premise is required (conversation-spec): + // a fresh conversation's first say states `{ tip: null }` rather than omitting it. + const statedTip = req.precondition?.tip ?? null; + if (statedTip !== tip) { + return encode({ rejected: true, reason: 'stale' }); + } + if (this.#busy) { + // A turn is live: the premise has a live acceptance. cancel-then-send is the affordance. + return encode({ rejected: true, reason: 'stale' }); + } + return null; + } + + async #resolveThenAcceptSay(req: SayRequest): Promise { + const resolved: ResolvedAttachment[] = []; + for (const block of req.attachments ?? []) { + if (block.source?.type !== 'object') { + // Source kinds are an open set; a kind this servicer cannot resolve makes the fresh say + // unresolvable here — never a silent placeholder (conversation-spec, say attachments). + return encode({ rejected: true, reason: 'attachment_unavailable', detail: `unsupported source type: ${String(block.source?.type)}` }); + } + const { id, bucket } = block.source; + if (id == null || id === '' || bucket == null || bucket === '') { + // No default and no fallback: a block naming no bucket cannot be resolved, full stop. + return encode({ rejected: true, reason: 'attachment_unavailable', detail: 'attachment reference carries no id or no bucket' }); + } + const mediaType = block.source.mediaType ?? 'application/octet-stream'; + if (!mediaType.startsWith('image/') && mediaType !== 'application/pdf') { + // Resolvable bytes the model cannot take are still unusable for THIS say — reject honestly + // rather than commit an attachment the request build would have to drop. + return encode({ rejected: true, reason: 'attachment_unavailable', detail: `media type not inlinable by this servicer: ${mediaType}` }); + } + const bytes = await this.bus.fetchObject(bucket, id); + if (bytes == null) { + return encode({ rejected: true, reason: 'attachment_unavailable', detail: `object ${id} not resolvable in bucket ${bucket}` }); + } + resolved.push({ base64: Buffer.from(bytes).toString('base64'), mediaType, sizeBytes: block.source.size ?? bytes.length }); + } + // The fetches awaited: re-check the premise before accepting — the conversation may have moved. + const premiseFailure = this.#checkSayPremise(req); + if (premiseFailure != null) { + return premiseFailure; + } + return this.#acceptSay(req, resolved); + } + + #acceptSay(req: SayRequest, resolved: readonly ResolvedAttachment[]): Uint8Array { + this.#busy = true; // close the gap before runTurn sets it + const queryId = randomUUID(); + const from: Sender = req.from ?? { kind: 'human' }; + if (req.attachments != null && req.attachments.length > 0) { + // The committed message carries the reference blocks verbatim, never the bytes (conversation-spec). + this.ledger.put(queryId, req.attachments); + } + this.logger.info('say accepted', { queryId, attachments: resolved.length }); + this.inbox.deliver({ text: req.text ?? '', queryId, from, attachments: resolved.length > 0 ? resolved : undefined }); + return encode({ accepted: true, id: queryId }); + } } diff --git a/apps/claude-sdk-cli/src/conv/WireAttachmentLedger.ts b/apps/claude-sdk-cli/src/conv/WireAttachmentLedger.ts new file mode 100644 index 00000000..e121748f --- /dev/null +++ b/apps/claude-sdk-cli/src/conv/WireAttachmentLedger.ts @@ -0,0 +1,31 @@ +/** A say's attachment reference block, as it arrived on the wire (conversation-spec, say attachments). */ +export type AttachmentReferenceBlock = { + type: string; + source: { type: string; id: string; bucket?: string; mediaType?: string; size?: number }; +}; + +/** The ledger's contract; register abstract→concrete and depend on the abstract (DI rule). */ +export abstract class IWireAttachmentLedger { + public abstract put(queryId: string, blocks: readonly AttachmentReferenceBlock[]): void; + public abstract take(queryId: string): readonly AttachmentReferenceBlock[] | null; +} + +/** + * Holds an accepted say's reference blocks until its user message commits, so the change stream can + * carry the blocks verbatim while the model-facing request carries the inlined bytes — the record + * stays light and wire-legal (conversation-spec: the committed message carries the reference block + * verbatim, never the bytes). + */ +export class WireAttachmentLedger extends IWireAttachmentLedger { + readonly #blocks = new Map(); + + public put(queryId: string, blocks: readonly AttachmentReferenceBlock[]): void { + this.#blocks.set(queryId, blocks); + } + + public take(queryId: string): readonly AttachmentReferenceBlock[] | null { + const blocks = this.#blocks.get(queryId) ?? null; + this.#blocks.delete(queryId); + return blocks; + } +} diff --git a/apps/claude-sdk-cli/src/conv/WireSayInbox.ts b/apps/claude-sdk-cli/src/conv/WireSayInbox.ts index ccd7817e..bc759737 100644 --- a/apps/claude-sdk-cli/src/conv/WireSayInbox.ts +++ b/apps/claude-sdk-cli/src/conv/WireSayInbox.ts @@ -1,7 +1,10 @@ import type { Sender } from '@shellicar/claude-sdk'; +/** An attachment resolved at the servicer's edge: the fetched bytes, ready to inline for the model. */ +export type ResolvedAttachment = { base64: string; mediaType: string; sizeBytes: number }; + /** An accepted wire say waiting for the main loop to pick it up. */ -export type AcceptedSay = { text: string; queryId: string; from: Sender }; +export type AcceptedSay = { text: string; queryId: string; from: Sender; attachments?: readonly ResolvedAttachment[] }; /** The one-slot hand-off's contract; register abstract→concrete and depend on the abstract (DI rule). */ export abstract class IWireSayInbox { diff --git a/apps/claude-sdk-cli/src/runAgent.ts b/apps/claude-sdk-cli/src/runAgent.ts index e5948498..4293d354 100644 --- a/apps/claude-sdk-cli/src/runAgent.ts +++ b/apps/claude-sdk-cli/src/runAgent.ts @@ -1,6 +1,7 @@ import type { Anthropic } from '@anthropic-ai/sdk'; -import type { BetaImageBlockParam, BetaTextBlockParam } from '@anthropic-ai/sdk/resources/beta.mjs'; +import type { BetaBase64PDFBlock, BetaImageBlockParam, BetaTextBlockParam } from '@anthropic-ai/sdk/resources/beta.mjs'; import type { QueryRunner, Sender, SystemReminder, TransformToolResult } from '@shellicar/claude-sdk'; +import type { ResolvedAttachment } from './conv/WireSayInbox.js'; import { logger } from './logger.js'; import type { ImageAttachment } from './model/CommandModeState.js'; import type { IConversationState } from './model/ConversationState.js'; @@ -18,6 +19,9 @@ export type UserInput = { * `accepted` reply, and the sender to echo as `from`. Absent for keyboard input. */ queryId?: string; from?: Sender; + /** Attachments resolved at the servicer's edge from a wire say's reference blocks — inlined for + * the model here; the committed message carries the reference blocks (ConvChangePublisher). */ + wireAttachments?: readonly ResolvedAttachment[]; }; export type RunAgentInput = { @@ -43,7 +47,7 @@ export function buildRunAgentInput(userInput: UserInput): RunAgentInput { if (userInput.resume) { return { displayText: '', message: null, queryId: userInput.queryId, from: userInput.from }; } - const contentBlocks: (BetaImageBlockParam | BetaTextBlockParam)[] = []; + const contentBlocks: (BetaImageBlockParam | BetaTextBlockParam | BetaBase64PDFBlock)[] = []; let displayText = userInput.text; for (const img of userInput.images) { @@ -53,15 +57,24 @@ export function buildRunAgentInput(userInput: UserInput): RunAgentInput { }); } + for (const att of userInput.wireAttachments ?? []) { + if (att.mediaType === 'application/pdf') { + contentBlocks.push({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: att.base64 } }); + } else { + contentBlocks.push({ type: 'image', source: { type: 'base64', media_type: att.mediaType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp', data: att.base64 } }); + } + } + if (userInput.text) { contentBlocks.push({ type: 'text', text: userInput.text }); } - if (userInput.images.length > 0) { - const imgSummary = userInput.images - .map((img) => { - const sz = img.sizeBytes >= 1024 ? `${(img.sizeBytes / 1024).toFixed(1)}KB` : `${img.sizeBytes}B`; - return `[image ${img.mediaType} ${sz}]`; + const summarised = [...userInput.images, ...(userInput.wireAttachments ?? [])]; + if (summarised.length > 0) { + const imgSummary = summarised + .map((att) => { + const sz = att.sizeBytes >= 1024 ? `${(att.sizeBytes / 1024).toFixed(1)}KB` : `${att.sizeBytes}B`; + return `[${att.mediaType === 'application/pdf' ? 'document' : 'image'} ${att.mediaType} ${sz}]`; }) .join(' '); displayText = displayText ? `${displayText}\n${imgSummary}` : imgSummary; diff --git a/apps/claude-sdk-cli/src/setup/AgentBusActivator.ts b/apps/claude-sdk-cli/src/setup/AgentBusActivator.ts index 16acd606..0f34f731 100644 --- a/apps/claude-sdk-cli/src/setup/AgentBusActivator.ts +++ b/apps/claude-sdk-cli/src/setup/AgentBusActivator.ts @@ -1,7 +1,9 @@ import { IFileSystem } from '@shellicar/claude-core/fs/interfaces'; +import { IConversation } from '@shellicar/claude-sdk'; import { dependsOn } from '@shellicar/core-di'; import { IAgentPresence } from '../agent/AgentPresence.js'; import { IAgentServe } from '../agent/AgentServe.js'; +import { IAttachmentGuard } from '../agent/AttachmentGuard.js'; import { IBus } from '../bus/IBus.js'; import { IConvServe } from '../conv/ConvServe.js'; import { IConversationSession } from '../model/ConversationSession.js'; @@ -27,6 +29,8 @@ export class AgentBusActivator extends IAgentBusActivator { @dependsOn(IAgentServe) private readonly agentServe!: IAgentServe; @dependsOn(IFileSystem) private readonly fileSystem!: IFileSystem; @dependsOn(IConversationSession) private readonly session!: IConversationSession; + @dependsOn(IConversation) private readonly conversation!: IConversation; + @dependsOn(IAttachmentGuard) private readonly attachmentGuard!: IAttachmentGuard; // When enabled and the broker is unreachable, bus.start() throws, propagating to entry/main.ts // which prints and exits 1. Disabled: start() returns before any connection or NATS import. @@ -35,6 +39,10 @@ export class AgentBusActivator extends IAgentBusActivator { this.convServe.bind(this.session.id); this.agentServe.bind(); this.agentPresence.boot(); - this.agentPresence.attach(this.session.id, this.fileSystem.cwd()); + // The claim carries the tip so an observer knows where the conversation stands without replaying + // the change stream (conversation-spec, Attachment); watch the leaf before claiming so a + // supersession is never missed in the gap. + this.attachmentGuard.watch(this.session.id); + this.agentPresence.attach(this.session.id, this.fileSystem.cwd(), this.conversation.items.at(-1)?.identity?.messageId ?? null); } } diff --git a/apps/claude-sdk-cli/src/setup/Application.ts b/apps/claude-sdk-cli/src/setup/Application.ts index 4110ef40..d0f3894c 100644 --- a/apps/claude-sdk-cli/src/setup/Application.ts +++ b/apps/claude-sdk-cli/src/setup/Application.ts @@ -150,7 +150,7 @@ export class Application extends IApplication { // premise rule keeps them from colliding into two turns — a say is accepted only while idle (§1.4). const nextInput = async (): Promise => { const fromKeyboard = this.editorHandler.waitForInput(); - const fromWire = this.wireSayInbox.next().then((s): UserInput => ({ text: s.text, images: [], queryId: s.queryId, from: s.from })); + const fromWire = this.wireSayInbox.next().then((s): UserInput => ({ text: s.text, images: [], queryId: s.queryId, from: s.from, wireAttachments: s.attachments })); return Promise.race([fromKeyboard, fromWire]); }; diff --git a/apps/claude-sdk-cli/src/setup/WorkingDirectoryMoveHandler.ts b/apps/claude-sdk-cli/src/setup/WorkingDirectoryMoveHandler.ts index 87c9567d..e98c5fff 100644 --- a/apps/claude-sdk-cli/src/setup/WorkingDirectoryMoveHandler.ts +++ b/apps/claude-sdk-cli/src/setup/WorkingDirectoryMoveHandler.ts @@ -67,9 +67,10 @@ export class WorkingDirectoryMoveHandler extends IWorkingDirectoryMoveHandler { this.rulesConfigNotifier.refresh(); this.statusState.setCwdBasename(basename(cwd)); void this.#reloadPromptsAfterMove(); - // The move landed: re-publish `attached` at the new cwd, last-write-wins (agent-spec, chdir). Fires - // for both a local /cd and a `chdir` request — WorkingDirectory.change is the one authoritative path. - this.agentPresence.attach(this.session.id, cwd); + // The move landed: publish `moved` — a fact about the standing claim, never a second `attached` + // (conversation-spec, Attachment). Fires for both a local /cd and a wire `chdir` — + // WorkingDirectory.change is the one authoritative path. + this.agentPresence.move(this.session.id, cwd); }); } diff --git a/apps/claude-sdk-cli/src/setup/container.ts b/apps/claude-sdk-cli/src/setup/container.ts index ca165c0b..ffde5f71 100644 --- a/apps/claude-sdk-cli/src/setup/container.ts +++ b/apps/claude-sdk-cli/src/setup/container.ts @@ -61,6 +61,7 @@ import { AuditWriter } from '../AuditWriter.js'; import { AgentPresence, IAgentPresence } from '../agent/AgentPresence.js'; import { AgentServe, IAgentServe } from '../agent/AgentServe.js'; import { AgentServicer, IAgentServicer } from '../agent/AgentServicer.js'; +import { AttachmentGuard, IAttachmentGuard } from '../agent/AttachmentGuard.js'; import { HistoryPresentation } from '../app/HistoryPresentation.js'; import type { Presentation } from '../app/Presentation.js'; import { PrimaryPresentation } from '../app/PrimaryPresentation.js'; @@ -85,6 +86,7 @@ import { ConvChangePublisher, IConvChangePublisher } from '../conv/ConvChangePub import { ConvServe, IConvServe } from '../conv/ConvServe.js'; import { ConvServicer, IConvServicer } from '../conv/ConvServicer.js'; import { ConvTelemetryProjector, IConvTelemetryProjector } from '../conv/ConvTelemetryProjector.js'; +import { IWireAttachmentLedger, WireAttachmentLedger } from '../conv/WireAttachmentLedger.js'; import { IWireSayInbox, WireSayInbox } from '../conv/WireSayInbox.js'; import { createAppTools } from '../createAppTools.js'; import { GitStateMonitor } from '../GitStateMonitor.js'; @@ -398,11 +400,13 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { services.register(ConsumerChannel).asSelf(); services.register(NatsBus).as(IBus); services.register(WireSayInbox).as(IWireSayInbox); + services.register(WireAttachmentLedger).as(IWireAttachmentLedger); services.register(ConvServicer).as(IConvServicer); services.register(ConvServe).as(IConvServe); services.register(ConvChangePublisher).as(IConvChangePublisher); services.register(ApprovalHolder).as(IApprovalHolder); services.register(AgentPresence).as(IAgentPresence); + services.register(AttachmentGuard).as(IAttachmentGuard); services.register(AgentServicer).as(IAgentServicer); services.register(AgentServe).as(IAgentServe); services.register(Secrets).as(ISecrets); diff --git a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts index 9bb19711..3f81cbf4 100644 --- a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts +++ b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts @@ -9,12 +9,14 @@ import { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import { describe, expect, it } from 'vitest'; import { z } from 'zod'; +import { IAgentPresence } from '../src/agent/AgentPresence.js'; import { ApprovalHolder, IApprovalHolder } from '../src/approval/ApprovalHolder.js'; import { IBus } from '../src/bus/IBus.js'; import { sdkConfigSchema } from '../src/cli-config/schema.js'; import { AgentMessageHandler } from '../src/controller/AgentMessageHandler.js'; import { ApprovalHandler } from '../src/controller/ApprovalHandler.js'; import { ConvChangePublisher, IConvChangePublisher } from '../src/conv/ConvChangePublisher.js'; +import { IWireAttachmentLedger, WireAttachmentLedger } from '../src/conv/WireAttachmentLedger.js'; import { logger } from '../src/logger.js'; import { ApprovalNotifier } from '../src/model/ApprovalNotifier.js'; import { ConversationSession, IConversationSession } from '../src/model/ConversationSession.js'; @@ -183,6 +185,11 @@ function makeHandler(overrides: OptsOverrides = {}) { .using(() => new CapturingBus()) .asSelf(); services.register(ApprovalHolder).as(IApprovalHolder); + services + .register(IAgentPresence) + .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, move: () => {}, detach: () => {}, hasClaim: () => true, stop: () => {} }) as IAgentPresence) + .asSelf(); + services.register(WireAttachmentLedger).as(IWireAttachmentLedger); services.register(ConvChangePublisher).as(IConvChangePublisher); services.register(ApprovalNotifier).asSelf(); services diff --git a/apps/claude-sdk-cli/test/AttachmentGuard.spec.ts b/apps/claude-sdk-cli/test/AttachmentGuard.spec.ts new file mode 100644 index 00000000..3409d0d2 --- /dev/null +++ b/apps/claude-sdk-cli/test/AttachmentGuard.spec.ts @@ -0,0 +1,124 @@ +import { Clock, Instant, ZoneOffset } from '@js-joda/core'; +import { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { createServiceCollection, Lifetime } from '@shellicar/core-di'; +import { describe, expect, it } from 'vitest'; +import { AgentPresence, IAgentPresence } from '../src/agent/AgentPresence.js'; +import { AttachmentGuard, IAttachmentGuard } from '../src/agent/AttachmentGuard.js'; +import { IBus } from '../src/bus/IBus.js'; +import { IConvServe } from '../src/conv/ConvServe.js'; +import { logger } from '../src/logger.js'; +import { IConversationState } from '../src/model/ConversationState.js'; +import { CapturingBus } from './CapturingBus.js'; + +const CONV = 'conv-abc'; +const clock = Clock.fixed(Instant.parse('2026-07-07T11:00:00Z'), ZoneOffset.ofHours(10)); +const encode = (body: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(body)); + +const fakeConfigLoader = { + get config() { + return { nats: { world: 'mac', pulseIntervalS: 30 } }; + }, +} as unknown as ConfigLoader; + +function build() { + const bus = new CapturingBus(); + const notices: string[] = []; + let unbound = 0; + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services + .register(IBus) + .using(() => bus) + .asSelf(); + services + .register(Clock) + .using(() => clock) + .asSelf(); + services + .register(ConfigLoader) + .using(() => fakeConfigLoader) + .asSelf(); + services.register(AgentPresence).as(IAgentPresence); + services + .register(IConvServe) + .using( + () => + ({ + bind: () => {}, + unbind: () => { + unbound += 1; + }, + }) as IConvServe, + ) + .asSelf(); + services + .register(IConversationState) + .using(() => ({ completeActive: () => {}, spliceNotice: (text: string) => notices.push(text) }) as unknown as IConversationState) + .asSelf(); + services + .register(ILogger) + .using(() => logger) + .asSelf(); + services.register(AttachmentGuard).as(IAttachmentGuard); + const provider = services.buildProvider(); + const guard = provider.resolve(IAttachmentGuard); + const presence = provider.resolve(IAgentPresence); + return { bus, guard, presence, notices, unboundCount: () => unbound }; +} + +function attachAndWatch() { + const built = build(); + built.guard.watch(CONV); + built.presence.attach(CONV, '~/repos/tower', null); + return built; +} + +describe('AttachmentGuard', () => { + it('ignores its own claim echoed back', () => { + const { bus, presence, notices } = attachAndWatch(); + bus.deliver(`conv.v2.${CONV}.attachment.attached`, encode({ ts: '2026-07-07T21:00:00+10:00', instanceId: presence.instanceId, world: 'mac' })); + const expected = 0; + const actual = notices.length; + expect(actual).toBe(expected); + }); + + it('publishes detached as the observable act of standing down when superseded', () => { + const { bus, presence } = attachAndWatch(); + bus.deliver(`conv.v2.${CONV}.attachment.attached`, encode({ ts: '2026-07-07T21:00:00+10:00', instanceId: 'inst-other', world: 'vm' })); + const expected = true; + const actual = bus.published.some((c) => c.subject === `conv.v2.${CONV}.attachment.detached` && c.body.instanceId === presence.instanceId); + expect(actual).toBe(expected); + }); + + it('closes the claim so a displaced instance stops committing', () => { + const { bus, presence } = attachAndWatch(); + bus.deliver(`conv.v2.${CONV}.attachment.attached`, encode({ ts: '2026-07-07T21:00:00+10:00', instanceId: 'inst-other', world: 'vm' })); + const expected = false; + const actual = presence.hasClaim(CONV); + expect(actual).toBe(expected); + }); + + it('drops the wire serve binding when superseded', () => { + const { bus, unboundCount } = attachAndWatch(); + bus.deliver(`conv.v2.${CONV}.attachment.attached`, encode({ ts: '2026-07-07T21:00:00+10:00', instanceId: 'inst-other', world: 'vm' })); + const expected = 1; + const actual = unboundCount(); + expect(actual).toBe(expected); + }); + + it('surfaces the displacement to the user', () => { + const { bus, notices } = attachAndWatch(); + bus.deliver(`conv.v2.${CONV}.attachment.attached`, encode({ ts: '2026-07-07T21:00:00+10:00', instanceId: 'inst-other', world: 'vm' })); + const expected = true; + const actual = notices.some((n) => n.includes('served by another instance')); + expect(actual).toBe(expected); + }); + + it('treats a same-instanceId claim from another world as displacement', () => { + const { bus, presence } = attachAndWatch(); + bus.deliver(`conv.v2.${CONV}.attachment.attached`, encode({ ts: '2026-07-07T21:00:00+10:00', instanceId: presence.instanceId, world: 'vm' })); + const expected = false; + const actual = presence.hasClaim(CONV); + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/CapturingBus.ts b/apps/claude-sdk-cli/test/CapturingBus.ts index 7af9b1b2..0ab31070 100644 --- a/apps/claude-sdk-cli/test/CapturingBus.ts +++ b/apps/claude-sdk-cli/test/CapturingBus.ts @@ -20,6 +20,8 @@ const matchesSubject = (pattern: string, subject: string): boolean => { export class CapturingBus extends IBus { public readonly published: Captured[] = []; public readonly serves = new Map(); + /** Objects the fake transit store holds, keyed `bucket/id`. */ + public readonly objects = new Map(); public async start(): Promise {} @@ -27,8 +29,20 @@ export class CapturingBus extends IBus { this.published.push({ subject, body: JSON.parse(new TextDecoder().decode(payload)) as Record }); } - public subscribe(): () => void { - return () => {}; + public readonly subscriptions = new Map void>(); + + public subscribe(subject: string, handler: (subject: string, payload: Uint8Array) => void): () => void { + this.subscriptions.set(subject, handler); + return () => this.subscriptions.delete(subject); + } + + /** Deliver a message to a matching subscription, as the broker would. */ + public deliver(subject: string, payload: Uint8Array): void { + for (const [pattern, handler] of this.subscriptions) { + if (matchesSubject(pattern, subject)) { + handler(subject, payload); + } + } } public async request(): Promise { @@ -42,7 +56,7 @@ export class CapturingBus extends IBus { /** Drive a served subject as NATS would: match a wildcard-bound serve and invoke it with the caller's * exact subject (v2's request handlers route on the leaf they actually received). */ - public callServe(subject: string, payload: Uint8Array): Uint8Array | undefined { + public callServe(subject: string, payload: Uint8Array): Uint8Array | Promise | undefined { for (const [pattern, handler] of this.serves) { if (matchesSubject(pattern, subject)) { return handler(payload, subject); @@ -51,5 +65,9 @@ export class CapturingBus extends IBus { return undefined; } + public async fetchObject(bucket: string, id: string): Promise { + return this.objects.get(`${bucket}/${id}`) ?? null; + } + public async stop(): Promise {} } diff --git a/apps/claude-sdk-cli/test/CommandIntentExecutor.spec.ts b/apps/claude-sdk-cli/test/CommandIntentExecutor.spec.ts index ef4d91aa..c707160a 100644 --- a/apps/claude-sdk-cli/test/CommandIntentExecutor.spec.ts +++ b/apps/claude-sdk-cli/test/CommandIntentExecutor.spec.ts @@ -9,6 +9,7 @@ import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import { describe, expect, it } from 'vitest'; import { AuditStats } from '../src/AuditStats.js'; import { IAgentPresence } from '../src/agent/AgentPresence.js'; +import { IAttachmentGuard } from '../src/agent/AttachmentGuard.js'; import { CommandIntentExecutor } from '../src/controller/CommandIntentExecutor.js'; import { IConvServe } from '../src/conv/ConvServe.js'; import { logger } from '../src/logger.js'; @@ -112,11 +113,15 @@ function makeExecutor(source: AttachmentSource) { services.register(AuditStats).asSelf(); // resolves the already-registered IFileSystem services .register(IConvServe) - .using(() => ({ bind: () => {} })) + .using(() => ({ bind: () => {}, unbind: () => {} })) .asSelf(); services .register(IAgentPresence) - .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, detach: () => {}, stop: () => {} })) + .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, move: () => {}, detach: () => {}, hasClaim: () => true, stop: () => {} })) + .asSelf(); + services + .register(IAttachmentGuard) + .using(() => ({ watch: () => {} })) .asSelf(); services.register(WorkingDirectory).asSelf().as(IWorkingDirectory); services.register(CommandIntentExecutor).asSelf(); diff --git a/apps/claude-sdk-cli/test/CommandKeyHandler.spec.ts b/apps/claude-sdk-cli/test/CommandKeyHandler.spec.ts index 6fa19e63..15cec305 100644 --- a/apps/claude-sdk-cli/test/CommandKeyHandler.spec.ts +++ b/apps/claude-sdk-cli/test/CommandKeyHandler.spec.ts @@ -9,6 +9,7 @@ import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import { describe, expect, it } from 'vitest'; import { AuditStats } from '../src/AuditStats.js'; import { IAgentPresence } from '../src/agent/AgentPresence.js'; +import { IAttachmentGuard } from '../src/agent/AttachmentGuard.js'; import { CommandIntentExecutor } from '../src/controller/CommandIntentExecutor.js'; import { CommandKeyHandler } from '../src/controller/CommandKeyHandler.js'; import { IConvServe } from '../src/conv/ConvServe.js'; @@ -112,11 +113,15 @@ function makeHandler(sourceText: string | null = null) { services.register(AuditStats).asSelf(); services .register(IConvServe) - .using(() => ({ bind: () => {} })) + .using(() => ({ bind: () => {}, unbind: () => {} })) .asSelf(); services .register(IAgentPresence) - .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, detach: () => {}, stop: () => {} })) + .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, move: () => {}, detach: () => {}, hasClaim: () => true, stop: () => {} })) + .asSelf(); + services + .register(IAttachmentGuard) + .using(() => ({ watch: () => {} })) .asSelf(); services.register(WorkingDirectory).asSelf().as(IWorkingDirectory); services.register(CommandIntentExecutor).asSelf(); diff --git a/apps/claude-sdk-cli/test/ConvChangePublisher.spec.ts b/apps/claude-sdk-cli/test/ConvChangePublisher.spec.ts index ae4def84..26c659fa 100644 --- a/apps/claude-sdk-cli/test/ConvChangePublisher.spec.ts +++ b/apps/claude-sdk-cli/test/ConvChangePublisher.spec.ts @@ -2,8 +2,10 @@ import { Clock, Instant, ZoneOffset } from '@js-joda/core'; import { IConversation } from '@shellicar/claude-sdk'; import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import { describe, expect, it } from 'vitest'; +import { IAgentPresence } from '../src/agent/AgentPresence.js'; import { IBus } from '../src/bus/IBus.js'; import { ConvChangePublisher, IConvChangePublisher } from '../src/conv/ConvChangePublisher.js'; +import { IWireAttachmentLedger, WireAttachmentLedger } from '../src/conv/WireAttachmentLedger.js'; import { CapturingBus } from './CapturingBus.js'; function buildPublisher(): { publisher: IConvChangePublisher; bus: CapturingBus } { @@ -21,6 +23,11 @@ function buildPublisher(): { publisher: IConvChangePublisher; bus: CapturingBus .register(Clock) .using(() => Clock.fixed(Instant.parse('2026-07-26T08:00:00Z'), ZoneOffset.UTC)) .asSelf(); + services + .register(IAgentPresence) + .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, move: () => {}, detach: () => {}, hasClaim: () => true, stop: () => {} }) as IAgentPresence) + .asSelf(); + services.register(WireAttachmentLedger).as(IWireAttachmentLedger); services.register(ConvChangePublisher).as(IConvChangePublisher); const publisher = services.buildProvider().resolve(IConvChangePublisher); return { publisher, bus }; diff --git a/apps/claude-sdk-cli/test/ConvServe.spec.ts b/apps/claude-sdk-cli/test/ConvServe.spec.ts index 06f47827..c3d99174 100644 --- a/apps/claude-sdk-cli/test/ConvServe.spec.ts +++ b/apps/claude-sdk-cli/test/ConvServe.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { IBus } from '../src/bus/IBus.js'; import { ConvServe, IConvServe } from '../src/conv/ConvServe.js'; import { ConvServicer, IConvServicer } from '../src/conv/ConvServicer.js'; +import { IWireAttachmentLedger, WireAttachmentLedger } from '../src/conv/WireAttachmentLedger.js'; import { IWireSayInbox, WireSayInbox } from '../src/conv/WireSayInbox.js'; import { logger } from '../src/logger.js'; import { ConsumerChannel } from '../src/setup/ConsumerChannel.js'; @@ -25,6 +26,7 @@ function buildConvServe(bus: CapturingBus): IConvServe { .asSelf(); services.register(Conversation).asSelf().as(IConversation); services.register(WireSayInbox).as(IWireSayInbox); + services.register(WireAttachmentLedger).as(IWireAttachmentLedger); services.register(ConsumerChannel).asSelf(); services .register(ILogger) diff --git a/apps/claude-sdk-cli/test/ViewHost.spec.ts b/apps/claude-sdk-cli/test/ViewHost.spec.ts index 9e3df5a8..0535156b 100644 --- a/apps/claude-sdk-cli/test/ViewHost.spec.ts +++ b/apps/claude-sdk-cli/test/ViewHost.spec.ts @@ -8,6 +8,7 @@ import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import { describe, expect, it } from 'vitest'; import { AuditStats } from '../src/AuditStats.js'; import { IAgentPresence } from '../src/agent/AgentPresence.js'; +import { IAttachmentGuard } from '../src/agent/AttachmentGuard.js'; import type { Presentation } from '../src/app/Presentation.js'; import { PrimaryPresentation } from '../src/app/PrimaryPresentation.js'; import { ViewHost } from '../src/app/ViewHost.js'; @@ -300,11 +301,15 @@ describe('ViewHost — escape routing through the primary chains', () => { services.register(AuditStats).asSelf(); services .register(IConvServe) - .using(() => ({ bind: () => {} })) + .using(() => ({ bind: () => {}, unbind: () => {} })) .asSelf(); services .register(IAgentPresence) - .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, detach: () => {}, stop: () => {} })) + .using(() => ({ instanceId: 'inst-test', world: 'test', boot: () => {}, attach: () => {}, move: () => {}, detach: () => {}, hasClaim: () => true, stop: () => {} })) + .asSelf(); + services + .register(IAttachmentGuard) + .using(() => ({ watch: () => {} })) .asSelf(); services.register(WorkingDirectory).as(IWorkingDirectory); services.register(CommandIntentExecutor).asSelf(); diff --git a/apps/claude-sdk-cli/test/WorkingDirectoryMoveHandler.spec.ts b/apps/claude-sdk-cli/test/WorkingDirectoryMoveHandler.spec.ts index e9fd07d7..8554aa7f 100644 --- a/apps/claude-sdk-cli/test/WorkingDirectoryMoveHandler.spec.ts +++ b/apps/claude-sdk-cli/test/WorkingDirectoryMoveHandler.spec.ts @@ -78,7 +78,7 @@ function buildMoveHandler(): Built { .asSelf(); services .register(IAgentPresence) - .using(() => ({ attach: () => {} }) as unknown as IAgentPresence) + .using(() => ({ attach: () => {}, move: () => {} }) as unknown as IAgentPresence) .asSelf(); services .register(IDurableConfigProvider) diff --git a/apps/claude-sdk-cli/test/producer.conformance.spec.ts b/apps/claude-sdk-cli/test/producer.conformance.spec.ts index 7ec50603..d56a059c 100644 --- a/apps/claude-sdk-cli/test/producer.conformance.spec.ts +++ b/apps/claude-sdk-cli/test/producer.conformance.spec.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { DatabaseSync } from 'node:sqlite'; import { Clock, Instant, ZoneOffset } from '@js-joda/core'; import { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader'; @@ -14,6 +14,7 @@ import { IBus } from '../src/bus/IBus.js'; import { ConvChangePublisher, IConvChangePublisher } from '../src/conv/ConvChangePublisher.js'; import { ConvTelemetryProjector, IConvTelemetryProjector } from '../src/conv/ConvTelemetryProjector.js'; import { telemetryLeaf } from '../src/conv/telemetryLeaf.js'; +import { IWireAttachmentLedger, WireAttachmentLedger } from '../src/conv/WireAttachmentLedger.js'; import { stamp } from '../src/conv/wire.js'; import { logger } from '../src/logger.js'; import { ConversationSession, IConversationSession } from '../src/model/ConversationSession.js'; @@ -50,6 +51,17 @@ const validatorFor = (name: string): ReturnType => { return validate; }; +/** Fixture-line validation: a subject with no schema artifact is a leaf this build does not speak + * (e.g. the retained v1-speaker attachment fixtures) — skipped, never failed (conformance.md). A + * producer's own capture stays strict: everything it publishes must have a schema. */ +const fixtureLineValid = (subject: string, message: Record): boolean => { + const name = schemaNameFor(subject); + if (!existsSync(new URL(`./spec/schemas/${name}.schema.json`, import.meta.url))) { + return true; + } + return validatorFor(name)(message) === true; +}; + // --------------------------------------------------------------------------- // Fixture + capture helpers. // --------------------------------------------------------------------------- @@ -98,6 +110,7 @@ const isSubsequence = (required: string[], actual: string[]): boolean => { // --------------------------------------------------------------------------- const CONV = 'conv-abc'; +const WORLD = 'mac'; const clock = Clock.fixed(Instant.parse('2026-07-07T11:00:00Z'), ZoneOffset.ofHours(10)); const durableStub = { @@ -135,6 +148,11 @@ function runConvProducer(): Captured[] { .register(Clock) .using(() => clock) .asSelf(); + services + .register(IAgentPresence) + .using(() => ({ instanceId: 'inst-test', world: WORLD, boot: () => {}, attach: () => {}, move: () => {}, detach: () => {}, hasClaim: () => true, stop: () => {} }) as IAgentPresence) + .asSelf(); + services.register(WireAttachmentLedger).as(IWireAttachmentLedger); services.register(ConvChangePublisher).as(IConvChangePublisher); services .register(IDurableConfigProvider) @@ -215,8 +233,6 @@ function runApprovalProducer(): Captured[] { // (detached). Fake timers fire the pulse on the configured interval. // --------------------------------------------------------------------------- -const WORLD = 'mac'; - const fakeConfigLoader = (world: string, pulseIntervalS: number): ConfigLoader => ({ get config() { @@ -246,7 +262,9 @@ function runAgentProducer(): Captured[] { try { presence.boot(); vi.advanceTimersByTime(30_000); - presence.attach(CONV, '~/repos/tower'); + presence.attach(CONV, '~/repos/tower', 'm12'); + presence.attach(CONV, '~/repos/tower', 'm12'); // re-call while the claim is open: must publish nothing + presence.move(CONV, '~/repos/tower/mvp'); presence.detach(CONV); } finally { vi.useRealTimers(); @@ -262,14 +280,28 @@ describe('producer conformance — agent', () => { expect(actual).toBe(expected); }); - it('emits the fixture telemetry events as an ordered subsequence', () => { + it('emits ready then pulse on the world telemetry tree', () => { const captured = runAgentProducer(); const expected = true; - const actual = isSubsequence(requiredLeavesOnClass('scenario-a1', 'agent', 'telemetry'), capturedLeavesOnClass(captured, 'telemetry')); + const actual = isSubsequence(['ready', 'pulse'], capturedLeavesOnClass(captured, 'telemetry')); + expect(actual).toBe(expected); + }); + + it('publishes the claim lifecycle on the conversation attachment leaf, exactly once per claim', () => { + const captured = runAgentProducer(); + const expected = ['attached', 'moved', 'detached']; + const actual = capturedLeavesOnClass(captured, 'attachment'); + expect(actual).toEqual(expected); + }); + + it('publishes attachment claims on the conversation tree, never the world tree', () => { + const captured = runAgentProducer(); + const expected = 0; + const actual = captured.filter((c) => c.subject.startsWith('agent.v1.') && (c.subject.endsWith('.attached') || c.subject.endsWith('.detached'))).length; expect(actual).toBe(expected); }); - it('carries the same instanceId on ready, pulse, and attached', () => { + it('carries the same instanceId on ready, pulse, and the claim events', () => { const captured = runAgentProducer(); const ids = new Set(captured.map((c) => c.body.instanceId).filter((v) => v !== undefined)); const expected = 1; @@ -277,27 +309,35 @@ describe('producer conformance — agent', () => { expect(actual).toBe(expected); }); - it('attaches carrying the conversation id and cwd', () => { + it('attaches carrying the identity pair, cwd, and tip', () => { const captured = runAgentProducer(); - const expected = { conversationId: CONV, cwd: '~/repos/tower' }; - const attached = captured.find((c) => c.subject.endsWith('.telemetry.attached'))?.body as { conversationId?: string; cwd?: string } | undefined; - const actual = { conversationId: attached?.conversationId, cwd: attached?.cwd }; + const expected = { world: WORLD, cwd: '~/repos/tower', tip: 'm12' }; + const attached = captured.find((c) => c.subject === `conv.v2.${CONV}.attachment.attached`)?.body as { world?: string; cwd?: string; tip?: string } | undefined; + const actual = { world: attached?.world, cwd: attached?.cwd, tip: attached?.tip }; expect(actual).toEqual(expected); }); it('attaches carrying the pulse interval, so a late joiner between pulses still learns the promise', () => { const captured = runAgentProducer(); const expected = 30; - const actual = captured.find((c) => c.subject.endsWith('.telemetry.attached'))?.body.intervalS; + const actual = captured.find((c) => c.subject === `conv.v2.${CONV}.attachment.attached`)?.body.intervalS; expect(actual).toBe(expected); }); - it('detaches carrying the same conversation id', () => { + it('publishes moved carrying the new cwd on a directory change under the open claim', () => { const captured = runAgentProducer(); - const expected = CONV; - const actual = captured.find((c) => c.subject.endsWith('.telemetry.detached'))?.body.conversationId; + const expected = '~/repos/tower/mvp'; + const actual = captured.find((c) => c.subject === `conv.v2.${CONV}.attachment.moved`)?.body.cwd; expect(actual).toBe(expected); }); + + it('detaches carrying the identity pair', () => { + const captured = runAgentProducer(); + const expected = { instanceId: true, world: WORLD }; + const detached = captured.find((c) => c.subject === `conv.v2.${CONV}.attachment.detached`)?.body as { instanceId?: string; world?: string } | undefined; + const actual = { instanceId: detached?.instanceId !== undefined, world: detached?.world }; + expect(actual).toEqual(expected); + }); }); // --------------------------------------------------------------------------- @@ -342,6 +382,22 @@ describe('producer conformance — conv v2', () => { expect(actual).toBe(expected); }); + it('carries the publishing instance id as envelope provenance on every change', () => { + const captured = runConvProducer(); + const expected = true; + const changes = captured.filter((c) => c.subject.includes('.changes.')); + const actual = changes.length > 0 && changes.every((c) => c.body.instanceId === 'inst-test'); + expect(actual).toBe(expected); + }); + + it('commits a tool_result delivery without a fabricated sender', () => { + const captured = runConvProducer(); + const toolResult = captured.map((c) => c.body).find((b) => Array.isArray(b.content) && (b.content as { type?: string }[]).some((block) => block.type === 'tool_result')); + const expected = undefined; + const actual = toolResult?.from; + expect(actual).toBe(expected); + }); + it('ends the closing round with the end_turn stop reason', () => { const captured = runConvProducer(); const expected = 'end_turn'; @@ -395,15 +451,15 @@ describe('producer conformance — approval', () => { describe('conformance schema artifacts', () => { it('validates every event line in every v1 fixture against its subject schema', () => { - const fixtures = ['plain-exchange', 'cancel', 'stale-premise', 'approval-answered', 'approval-died']; + const fixtures = ['scenario-1', 'scenario-2', 'scenario-2b', 'scenario-3', 'scenario-4', 'scenario-5', 'scenario-6a', 'scenario-6b', 'scenario-7', 'scenario-8a', 'scenario-8b']; const lines = fixtures.flatMap((name) => fixtureLines(name)); const expected = true; - const actual = lines.every((l) => validatorFor(schemaNameFor(l.subject))(l.message)); + const actual = lines.every((l) => fixtureLineValid(l.subject, l.message)); expect(actual).toBe(expected); }); it('validates every reply in every v1 fixture against its concern reply schema', () => { - const fixtures = ['plain-exchange', 'cancel', 'stale-premise', 'approval-answered']; + const fixtures = ['scenario-1', 'scenario-2', 'scenario-2b', 'scenario-3', 'scenario-6a', 'scenario-8a', 'scenario-8b']; const replies = fixtures.flatMap((name) => fixtureLines(name).filter((l) => l.reply !== undefined)); const expected = true; const actual = replies.every((l) => validatorFor(`${l.subject.split('.')[0]}.reply`)(l.reply)); @@ -411,15 +467,15 @@ describe('conformance schema artifacts', () => { }); it('validates every event line in every v2 conv fixture against its subject schema', () => { - const fixtures = ['scenario-1', 'scenario-2', 'scenario-2b', 'scenario-3', 'scenario-4', 'scenario-5', 'scenario-7']; + const fixtures = ['scenario-1', 'scenario-2', 'scenario-2b', 'scenario-3', 'scenario-4', 'scenario-5', 'scenario-7', 'scenario-8a', 'scenario-8b']; const lines = fixtures.flatMap((name) => fixtureLines(name, 'v2')); const expected = true; - const actual = lines.every((l) => validatorFor(schemaNameFor(l.subject))(l.message)); + const actual = lines.every((l) => fixtureLineValid(l.subject, l.message)); expect(actual).toBe(expected); }); it('validates every reply in every v2 conv fixture against the v2 reply schema', () => { - const fixtures = ['scenario-1', 'scenario-2', 'scenario-2b', 'scenario-5']; + const fixtures = ['scenario-1', 'scenario-2', 'scenario-2b', 'scenario-5', 'scenario-8a', 'scenario-8b']; const replies = fixtures.flatMap((name) => fixtureLines(name, 'v2').filter((l) => l.reply !== undefined)); const expected = true; const actual = replies.every((l) => validatorFor('conv.v2.reply')(l.reply)); @@ -427,10 +483,10 @@ describe('conformance schema artifacts', () => { }); it('validates every event line in every agent fixture against its subject schema', () => { - const fixtures = ['scenario-a1', 'scenario-a2', 'scenario-a3', 'scenario-a4', 'scenario-a5']; + const fixtures = ['scenario-a1', 'scenario-a2', 'scenario-a3', 'scenario-a4', 'scenario-a5', 'scenario-a6', 'scenario-a7', 'scenario-a8', 'scenario-a9', 'scenario-a10']; const lines = fixtures.flatMap((name) => fixtureLines(name, 'agent')); const expected = true; - const actual = lines.every((l) => validatorFor(schemaNameFor(l.subject))(l.message)); + const actual = lines.every((l) => fixtureLineValid(l.subject, l.message)); expect(actual).toBe(expected); }); @@ -442,6 +498,13 @@ describe('conformance schema artifacts', () => { expect(actual).toBe(expected); }); + it('validates the new-leaf attachment fixture lines against the conv.v2 attachment schemas strictly', () => { + const lines = ['scenario-a6', 'scenario-a7', 'scenario-a8', 'scenario-a9', 'scenario-a10'].flatMap((name) => fixtureLines(name, 'agent')).filter((l) => l.subject.includes('.attachment.')); + const expected = true; + const actual = lines.length > 0 && lines.every((l) => validatorFor(schemaNameFor(l.subject))(l.message) === true); + expect(actual).toBe(expected); + }); + it('rejects a known telemetry event missing a required field', () => { const expected = false; const actual = validatorFor('conv.v2.telemetry.turn.ended')({ ts: '2026-07-07T21:00:00+10:00', queryId: 'q1', turnId: 't1' }); diff --git a/apps/claude-sdk-cli/test/servicer.conformance.spec.ts b/apps/claude-sdk-cli/test/servicer.conformance.spec.ts index 7e34fc44..fc72f264 100644 --- a/apps/claude-sdk-cli/test/servicer.conformance.spec.ts +++ b/apps/claude-sdk-cli/test/servicer.conformance.spec.ts @@ -9,6 +9,7 @@ import { AgentServicer, IAgentServicer } from '../src/agent/AgentServicer.js'; import { ApprovalHolder, IApprovalHolder } from '../src/approval/ApprovalHolder.js'; import { IBus } from '../src/bus/IBus.js'; import { ConvServicer, IConvServicer } from '../src/conv/ConvServicer.js'; +import { IWireAttachmentLedger, WireAttachmentLedger } from '../src/conv/WireAttachmentLedger.js'; import { IWireSayInbox, WireSayInbox } from '../src/conv/WireSayInbox.js'; import { logger } from '../src/logger.js'; import { ConversationSession, IConversationSession } from '../src/model/ConversationSession.js'; @@ -33,7 +34,7 @@ const decode = (payload: Uint8Array): Reply => JSON.parse(new TextDecoder().deco // the reply discipline. // --------------------------------------------------------------------------- -function buildConvServicer(tip: string): IConvServicer { +function buildConvServicer(tip: string, bus = new CapturingBus()): IConvServicer { const conversation = new Conversation(); const identity: MessageIdentity = { messageId: tip, turnId: 't2', queryId: 'q1', from: { kind: 'agent' } }; conversation.push({ role: 'assistant', content: [{ type: 'text', text: 'File X contains a summary' }] }, { identity }); @@ -45,6 +46,11 @@ function buildConvServicer(tip: string): IConvServicer { .asSelf() .as(IConversation); services.register(WireSayInbox).as(IWireSayInbox); + services.register(WireAttachmentLedger).as(IWireAttachmentLedger); + services + .register(IBus) + .using(() => bus) + .asSelf(); services.register(ConsumerChannel).asSelf(); services .register(ILogger) @@ -56,63 +62,91 @@ function buildConvServicer(tip: string): IConvServicer { const say = (text: string, tip: string | null): Uint8Array => encode({ ts: TS, from: { kind: 'human', userId: 'stephen' }, text, precondition: { tip } }); const cancel = (id: string): Uint8Array => encode({ ts: TS, from: { kind: 'human' }, id }); +const sayWithAttachment = (tip: string, source: Record): Uint8Array => encode({ ts: TS, from: { kind: 'human', userId: 'stephen' }, text: 'what does this show?', attachments: [{ type: 'image', source }], precondition: { tip } }); + +const handle = async (servicer: IConvServicer, payload: Uint8Array, subject: string): Promise => decode(await servicer.handle(payload, subject)); describe('servicer conformance — conv', () => { - it('accepts a say whose premise holds', () => { + it('accepts a say whose premise holds', async () => { const servicer = buildConvServicer('m4'); const expected = true; - const actual = decode(servicer.handle(say('okay, delete it', 'm4'), 'conv.v2.conv-abc.requests.say')).accepted; + const actual = (await handle(servicer, say('okay, delete it', 'm4'), 'conv.v2.conv-abc.requests.say')).accepted; expect(actual).toBe(expected); }); - it('returns an id for an accepted say', () => { + it('returns an id for an accepted say', async () => { const servicer = buildConvServicer('m4'); const expected = 'string'; - const actual = typeof decode(servicer.handle(say('okay, delete it', 'm4'), 'conv.v2.conv-abc.requests.say')).id; + const actual = typeof (await handle(servicer, say('okay, delete it', 'm4'), 'conv.v2.conv-abc.requests.say')).id; expect(actual).toBe(expected); }); - it('rejects a say whose premise is stale', () => { + it('rejects a say whose premise is stale', async () => { const servicer = buildConvServicer('m4'); const expected = 'stale'; - const actual = decode(servicer.handle(say('keep it, actually', 'm1'), 'conv.v2.conv-abc.requests.say')).reason; + const actual = (await handle(servicer, say('keep it, actually', 'm1'), 'conv.v2.conv-abc.requests.say')).reason; + expect(actual).toBe(expected); + }); + + it('accepts a say whose attachment resolves from the bucket the block names', async () => { + const bus = new CapturingBus(); + bus.objects.set('attach/att-7c9e', new Uint8Array([1, 2, 3])); + const servicer = buildConvServicer('m4', bus); + const expected = true; + const actual = (await handle(servicer, sayWithAttachment('m4', { type: 'object', id: 'att-7c9e', bucket: 'attach', mediaType: 'image/png', size: 3 }), 'conv.v2.conv-abc.requests.say')).accepted; expect(actual).toBe(expected); }); - it('answers cancel with no running query already_complete', () => { + it('rejects a say whose fresh attachment does not resolve attachment_unavailable', async () => { + const servicer = buildConvServicer('m4'); + const expected = 'attachment_unavailable'; + const actual = (await handle(servicer, sayWithAttachment('m4', { type: 'object', id: 'att-gone', bucket: 'attach', mediaType: 'image/png' }), 'conv.v2.conv-abc.requests.say')).reason; + expect(actual).toBe(expected); + }); + + it('rejects a say whose attachment block names no bucket attachment_unavailable', async () => { + const bus = new CapturingBus(); + bus.objects.set('attach/att-7c9e', new Uint8Array([1, 2, 3])); + const servicer = buildConvServicer('m4', bus); + const expected = 'attachment_unavailable'; + const actual = (await handle(servicer, sayWithAttachment('m4', { type: 'object', id: 'att-7c9e', mediaType: 'image/png' }), 'conv.v2.conv-abc.requests.say')).reason; + expect(actual).toBe(expected); + }); + + it('answers cancel with no running query already_complete', async () => { const servicer = buildConvServicer('m4'); const expected = 'already_complete'; - const actual = decode(servicer.handle(cancel('q2'), 'conv.v2.conv-abc.requests.cancel')).reason; + const actual = (await handle(servicer, cancel('q2'), 'conv.v2.conv-abc.requests.cancel')).reason; expect(actual).toBe(expected); }); - it('rejects a busy cancel whose id does not match the running query not_found', () => { + it('rejects a busy cancel whose id does not match the running query not_found', async () => { const servicer = buildConvServicer('m4'); servicer.setBusy(true); const expected = 'not_found'; - const actual = decode(servicer.handle(cancel('q2'), 'conv.v2.conv-abc.requests.cancel')).reason; + const actual = (await handle(servicer, cancel('q2'), 'conv.v2.conv-abc.requests.cancel')).reason; expect(actual).toBe(expected); }); - it('accepts a busy cancel whose id matches the running query', () => { + it('accepts a busy cancel whose id matches the running query', async () => { const servicer = buildConvServicer('m4'); servicer.setBusy(true); const expected = true; - const actual = decode(servicer.handle(cancel('q1'), 'conv.v2.conv-abc.requests.cancel')).accepted; + const actual = (await handle(servicer, cancel('q1'), 'conv.v2.conv-abc.requests.cancel')).accepted; expect(actual).toBe(expected); }); - it('answers revise unsupported', () => { + it('answers revise unsupported', async () => { const servicer = buildConvServicer('m4'); const expected = 'unsupported'; - const actual = decode(servicer.handle(encode({ ts: TS, from: { kind: 'agent' }, messageId: 'm2', content: [] }), 'conv.v2.conv-abc.requests.revise')).reason; + const actual = (await handle(servicer, encode({ ts: TS, from: { kind: 'agent' }, messageId: 'm2', content: [] }), 'conv.v2.conv-abc.requests.revise')).reason; expect(actual).toBe(expected); }); - it('answers an unknown request unsupported', () => { + it('answers an unknown request unsupported', async () => { const servicer = buildConvServicer('m4'); const expected = 'unsupported'; - const actual = decode(servicer.handle(encode({ ts: TS, from: { kind: 'human' } }), 'conv.v2.conv-abc.requests.history')).reason; + const actual = (await handle(servicer, encode({ ts: TS, from: { kind: 'human' } }), 'conv.v2.conv-abc.requests.history')).reason; expect(actual).toBe(expected); }); }); @@ -141,27 +175,27 @@ const answerReq = (approved: boolean): Uint8Array => encode({ type: 'answer', ts const req = { type: 'tool_approval_request', requestId: 'apr-1', name: 'DeleteFile', input: { content: { type: 'files', values: ['./old.ts'] } } } satisfies SdkToolApprovalRequest; describe('servicer conformance — approval', () => { - it('accepts the first valid answer', () => { + it('accepts the first valid answer', async () => { const bus = new CapturingBus(); const holder = buildApprovalHolder(bus); void holder.raise(req, { conversationId: 'conv-abc', toolUseId: 'toolu_02DEF' }); const handler = bus.serves.get('approval.v1.apr-1.requests'); const expected = true; - const actual = handler !== undefined ? decode(handler(answerReq(true), 'approval.v1.apr-1.requests')).accepted : undefined; + const actual = handler !== undefined ? decode(await handler(answerReq(true), 'approval.v1.apr-1.requests')).accepted : undefined; expect(actual).toBe(expected); }); - it('rejects a second answer already_settled', () => { + it('rejects a second answer already_settled', async () => { const bus = new CapturingBus(); const holder = buildApprovalHolder(bus); void holder.raise(req, { conversationId: 'conv-abc', toolUseId: 'toolu_02DEF' }); const handler = bus.serves.get('approval.v1.apr-1.requests'); if (handler !== undefined) { - handler(answerReq(true), 'approval.v1.apr-1.requests'); + await handler(answerReq(true), 'approval.v1.apr-1.requests'); holder.settle('apr-1', { approved: true, by: { kind: 'human', userId: 'stephen' } }); } const expected = 'already_settled'; - const actual = handler !== undefined ? decode(handler(answerReq(false), 'approval.v1.apr-1.requests')).reason : undefined; + const actual = handler !== undefined ? decode(await handler(answerReq(false), 'approval.v1.apr-1.requests')).reason : undefined; expect(actual).toBe(expected); }); }); @@ -216,6 +250,20 @@ describe('servicer conformance — agent', () => { expect(actual).toBe(expected); }); + it('rejects a service request with no conversationId invalid', () => { + const servicer = buildAgentServicer('conv-abc'); + const expected = 'invalid'; + const actual = decode(servicer.handle(encode({ ts: TS, from: { kind: 'orchestrator' } }), 'agent.v1.mac.requests.service')).reason; + expect(actual).toBe(expected); + }); + + it('rejects a chdir with no cwd invalid', () => { + const servicer = buildAgentServicer('conv-abc'); + const expected = 'invalid'; + const actual = decode(servicer.handle(encode({ ts: TS, from: { kind: 'human' }, conversationId: 'conv-abc' }), 'agent.v1.mac.requests.chdir')).reason; + expect(actual).toBe(expected); + }); + it('accepts drain', () => { const servicer = buildAgentServicer('conv-abc'); const expected = true; diff --git a/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a10.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a10.jsonl new file mode 100644 index 00000000..a962af20 --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a10.jsonl @@ -0,0 +1,3 @@ +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:00:00+10:00","instanceId":"inst-1a2f","world":"mac"}} +{"subject":"conv.v2.conv-abc.attachment.detached","message":{"ts":"2026-07-26T19:01:00+10:00","instanceId":"inst-1a2f","world":"mac"}} +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:02:00+10:00","instanceId":"inst-1a2f","world":"mac"}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a6.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a6.jsonl new file mode 100644 index 00000000..256462a4 --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a6.jsonl @@ -0,0 +1,5 @@ +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:00:00+10:00","instanceId":"inst-1a2f","world":"mac","cwd":"~/repos/tower"}} +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:01:00+10:00","instanceId":"inst-9c4d","world":"vm","cwd":"~/repos/tower"}} +{"subject":"conv.v2.conv-abc.attachment.detached","message":{"ts":"2026-07-26T19:02:00+10:00","instanceId":"inst-9c4d","world":"vm"}} +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:03:00+10:00","instanceId":"inst-1a2f","world":"mac","cwd":"~/repos/tower"}} +{"subject":"conv.v2.conv-abc.attachment.detached","message":{"ts":"2026-07-26T19:04:00+10:00","instanceId":"inst-1a2f","world":"mac"}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a7.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a7.jsonl new file mode 100644 index 00000000..68e8609b --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a7.jsonl @@ -0,0 +1,2 @@ +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:00:00+10:00","instanceId":"inst-1a2f","world":"mac","cwd":"~/repos/tower"}} +{"subject":"conv.v2.conv-abc.attachment.detached","message":{"ts":"2026-07-26T19:05:00+10:00","instanceId":"inst-1a2f","world":"mac"}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a8.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a8.jsonl new file mode 100644 index 00000000..de353649 --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a8.jsonl @@ -0,0 +1,2 @@ +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:00:00+10:00","instanceId":"inst-1a2f","world":"mac","cwd":"~/repos/tower"}} +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:10:00+10:00","instanceId":"inst-9c4d","world":"vm","cwd":"~/repos/tower"}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a9.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a9.jsonl new file mode 100644 index 00000000..832ef237 --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/agent/scenario-a9.jsonl @@ -0,0 +1,3 @@ +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:00:00+10:00","instanceId":"inst-1a2f","world":"mac","cwd":"~/repos/tower"}} +{"subject":"conv.v2.conv-abc.attachment.attached","message":{"ts":"2026-07-26T19:01:00+10:00","instanceId":"inst-9c4d","world":"vm","cwd":"~/repos/tower"}} +{"subject":"conv.v2.conv-abc.attachment.detached","message":{"ts":"2026-07-26T19:01:05+10:00","instanceId":"inst-1a2f","world":"mac"}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/plain-exchange.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-1.jsonl similarity index 96% rename from apps/claude-sdk-cli/test/spec/fixtures/plain-exchange.jsonl rename to apps/claude-sdk-cli/test/spec/fixtures/scenario-1.jsonl index b52e70dd..de426dcd 100644 --- a/apps/claude-sdk-cli/test/spec/fixtures/plain-exchange.jsonl +++ b/apps/claude-sdk-cli/test/spec/fixtures/scenario-1.jsonl @@ -1,4 +1,4 @@ -{"subject":"conv.v1.conv-abc.requests","message":{"type":"say","ts":"2026-07-07T21:00:00+10:00","from":{"kind":"human","userId":"stephen"},"text":"read file X and summarise it"},"reply":{"accepted":true,"id":"q1"}} +{"subject":"conv.v1.conv-abc.requests","message":{"type":"say","ts":"2026-07-07T21:00:00+10:00","from":{"kind":"human","userId":"stephen"},"text":"read file X and summarise it","precondition":{"tip":null}},"reply":{"accepted":true,"id":"q1"}} {"subject":"conv.v1.conv-abc.changes","message":{"type":"message","ts":"2026-07-07T21:00:00+10:00","id":"m1","queryId":"q1","turnId":"t1","role":"user","from":{"kind":"human","userId":"stephen"},"content":[{"type":"text","text":"read file X and summarise it"}]}} {"subject":"conv.v1.conv-abc.telemetry","message":{"type":"turn_started","ts":"2026-07-07T21:00:00+10:00","queryId":"q1","turnId":"t1","service":"anthropic.messages","model":"claude-sonnet-4-5","thinking":false,"maxTokens":8192}} {"subject":"conv.v1.conv-abc.telemetry","message":{"type":"tool_use","ts":"2026-07-07T21:00:00+10:00","queryId":"q1","turnId":"t1","id":"toolu_01ABC","name":"ReadFile","input":{"path":"X"}}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/cancel.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-2.jsonl similarity index 100% rename from apps/claude-sdk-cli/test/spec/fixtures/cancel.jsonl rename to apps/claude-sdk-cli/test/spec/fixtures/scenario-2.jsonl diff --git a/apps/claude-sdk-cli/test/spec/fixtures/scenario-2b.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-2b.jsonl new file mode 100644 index 00000000..8a5b53b1 --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/scenario-2b.jsonl @@ -0,0 +1,6 @@ +{"subject":"conv.v1.conv-abc.requests","message":{"type":"say","ts":"2026-07-07T21:00:00+10:00","from":{"kind":"human","userId":"stephen"},"text":"now delete it","precondition":{"tip":"m4"}},"reply":{"accepted":true,"id":"q2"}} +{"subject":"conv.v1.conv-abc.changes","message":{"type":"message","ts":"2026-07-07T21:00:00+10:00","id":"m5","queryId":"q2","turnId":"t3","role":"user","from":{"kind":"human","userId":"stephen"},"content":[{"type":"text","text":"now delete it"}]}} +{"subject":"conv.v1.conv-abc.telemetry","message":{"type":"turn_started","ts":"2026-07-07T21:00:00+10:00","queryId":"q2","turnId":"t3","service":"anthropic.messages","model":"claude-sonnet-4-5","thinking":false,"maxTokens":8192}} +{"subject":"conv.v1.conv-abc.telemetry","message":{"type":"turn_ended","ts":"2026-07-07T21:00:00+10:00","queryId":"q2","turnId":"t3","stopReason":"end_turn"}} +{"subject":"conv.v1.conv-abc.changes","message":{"type":"message","ts":"2026-07-07T21:00:00+10:00","id":"m6","queryId":"q2","turnId":"t3","role":"assistant","from":{"kind":"agent"},"content":[{"type":"text","text":"Deleted."}]}} +{"subject":"conv.v1.conv-abc.requests","message":{"type":"cancel","ts":"2026-07-07T21:00:00+10:00","from":{"kind":"human","userId":"stephen"},"id":"q2"},"reply":{"rejected":true,"reason":"already_complete"}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/scenario-3.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-3.jsonl new file mode 100644 index 00000000..ef7b8765 --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/scenario-3.jsonl @@ -0,0 +1,4 @@ +{"subject":"conv.v1.conv-abc.changes","message":{"type":"tip_moved","ts":"2026-07-07T21:00:00+10:00","to":"m1"}} +{"subject":"conv.v1.conv-abc.changes","message":{"type":"message","ts":"2026-07-07T21:00:00+10:00","id":"m5","queryId":"q2","turnId":"t4","role":"user","from":{"kind":"human","userId":"stephen"},"content":[{"type":"text","text":"read file Y and summarise it"}]}} +{"subject":"conv.v1.conv-abc.changes","message":{"type":"message","ts":"2026-07-07T21:00:00+10:00","id":"m6","queryId":"q2","turnId":"t4","role":"assistant","from":{"kind":"agent"},"content":[{"type":"text","text":"File Y contains…"}]}} +{"subject":"conv.v1.conv-abc.changes","message":{"type":"tip_moved","ts":"2026-07-07T21:00:00+10:00","to":"m4"}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/scenario-4.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-4.jsonl new file mode 100644 index 00000000..83c1ad9b --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/scenario-4.jsonl @@ -0,0 +1,2 @@ +{"subject":"conv.v1.conv-abc.changes","message":{"type":"revision","ts":"2026-07-07T21:00:00+10:00","messageId":"m2","content":[{"type":"tool_use","id":"toolu_01ABC","name":"ReadFile","input":{"path":"X"}}]}} +{"subject":"conv.v1.conv-abc.changes","message":{"type":"revision","ts":"2026-07-07T21:00:00+10:00","messageId":"m3","content":[{"type":"tool_result","tool_use_id":"toolu_01ABC","content":"…trimmed…"}]}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/stale-premise.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-5.jsonl similarity index 100% rename from apps/claude-sdk-cli/test/spec/fixtures/stale-premise.jsonl rename to apps/claude-sdk-cli/test/spec/fixtures/scenario-5.jsonl diff --git a/apps/claude-sdk-cli/test/spec/fixtures/approval-answered.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-6a.jsonl similarity index 100% rename from apps/claude-sdk-cli/test/spec/fixtures/approval-answered.jsonl rename to apps/claude-sdk-cli/test/spec/fixtures/scenario-6a.jsonl diff --git a/apps/claude-sdk-cli/test/spec/fixtures/approval-died.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-6b.jsonl similarity index 100% rename from apps/claude-sdk-cli/test/spec/fixtures/approval-died.jsonl rename to apps/claude-sdk-cli/test/spec/fixtures/scenario-6b.jsonl diff --git a/apps/claude-sdk-cli/test/spec/fixtures/scenario-7.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-7.jsonl new file mode 100644 index 00000000..fbdf822a --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/scenario-7.jsonl @@ -0,0 +1,10 @@ +{"subject":"conv.v1.conv-abc.deltas","message":{"type":"block","blockType":"thinking"}} +{"subject":"conv.v1.conv-abc.deltas","message":{"type":"delta","text":"The file has to go — checking wha"}} +{"subject":"conv.v1.conv-abc.deltas","message":{"type":"delta","text":"t references it first."}} +{"subject":"conv.v1.conv-abc.deltas","message":{"type":"block","blockType":"text"}} +{"subject":"conv.v1.conv-abc.deltas","message":{"type":"delta","text":"Deleting the old module — nothing"}} +{"subject":"conv.v1.conv-abc.deltas","message":{"type":"delta","text":" imports it any more."}} +{"subject":"conv.v1.conv-abc.deltas","message":{"type":"block","blockType":"tool_use"}} +{"subject":"conv.v1.conv-abc.deltas","message":{"type":"delta","text":"{\"files\": [\"./o"}} +{"subject":"conv.v1.conv-abc.deltas","message":{"type":"delta","text":"ld.ts\"]}"}} +{"subject":"conv.v1.conv-abc.changes","message":{"type":"message","ts":"2026-07-07T21:00:00+10:00","id":"m7","queryId":"q3","turnId":"t5","role":"assistant","from":{"kind":"agent"},"content":[{"type":"thinking","thinking":"The file has to go — checking what references it first."},{"type":"text","text":"Deleting the old module — nothing imports it any more."},{"type":"tool_use","id":"toolu_04JKL","name":"DeleteFile","input":{"files":["./old.ts"]}}]}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/scenario-8a.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-8a.jsonl new file mode 100644 index 00000000..5337016a --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/scenario-8a.jsonl @@ -0,0 +1,2 @@ +{"subject":"conv.v1.conv-abc.requests","message":{"type":"say","ts":"2026-07-07T21:00:00+10:00","from":{"kind":"human","userId":"stephen"},"text":"what does this show?","attachments":[{"type":"image","source":{"type":"object","id":"att-7c9e…","bucket":"attach","mediaType":"image/png","size":48213}}],"precondition":{"tip":"m4"}},"reply":{"accepted":true,"id":"q9"}} +{"subject":"conv.v1.conv-abc.changes","message":{"type":"message","ts":"2026-07-07T21:00:00+10:00","id":"m5","queryId":"q9","turnId":"t3","role":"user","from":{"kind":"human","userId":"stephen"},"content":[{"type":"image","source":{"type":"object","id":"att-7c9e…","bucket":"attach","mediaType":"image/png","size":48213}},{"type":"text","text":"what does this show?"}]}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/scenario-8b.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/scenario-8b.jsonl new file mode 100644 index 00000000..8c1e0a2e --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/scenario-8b.jsonl @@ -0,0 +1 @@ +{"subject":"conv.v1.conv-abc.requests","message":{"type":"say","ts":"2026-07-07T21:00:00+10:00","from":{"kind":"human","userId":"stephen"},"text":"what does this show?","attachments":[{"type":"image","source":{"type":"object","id":"att-7c9e…","mediaType":"image/png","size":48213}}],"precondition":{"tip":"m4"}},"reply":{"rejected":true,"reason":"attachment_unavailable"}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-1.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-1.jsonl index 4d702ee2..ce316a52 100644 --- a/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-1.jsonl +++ b/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-1.jsonl @@ -5,7 +5,7 @@ {"subject":"conv.v2.conv-abc.telemetry.turn.ended","message":{"ts":"2026-07-07T21:00:00+10:00","queryId":"q1","turnId":"t1","stopReason":"tool_use"}} {"subject":"conv.v2.conv-abc.telemetry.usage","message":{"ts":"2026-07-07T21:00:00+10:00","queryId":"q1","turnId":"t1","service":"anthropic.messages","model":"claude-sonnet-4-5","inputTokens":1200,"cacheCreationTokens":0,"cacheReadTokens":0,"outputTokens":80,"costUsd":0.005}} {"subject":"conv.v2.conv-abc.changes.message","message":{"ts":"2026-07-07T21:00:00+10:00","id":"m2","queryId":"q1","turnId":"t1","role":"assistant","from":{"kind":"agent"},"content":[{"type":"tool_use","id":"toolu_01ABC","name":"ReadFile","input":{"path":"X"}}]}} -{"subject":"conv.v2.conv-abc.changes.message","message":{"ts":"2026-07-07T21:00:00+10:00","id":"m3","queryId":"q1","turnId":"t2","role":"user","from":{"kind":"agent"},"content":[{"type":"tool_result","tool_use_id":"toolu_01ABC","content":"…file contents…"}]}} +{"subject":"conv.v2.conv-abc.changes.message","message":{"ts":"2026-07-07T21:00:00+10:00","id":"m3","queryId":"q1","turnId":"t2","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01ABC","content":"…file contents…"}]}} {"subject":"conv.v2.conv-abc.telemetry.turn.started","message":{"ts":"2026-07-07T21:00:00+10:00","queryId":"q1","turnId":"t2","service":"anthropic.messages","model":"claude-sonnet-4-5","thinking":false,"maxTokens":8192}} {"subject":"conv.v2.conv-abc.deltas","message":{"type":"delta","text":"File X contains"}} {"subject":"conv.v2.conv-abc.telemetry.turn.ended","message":{"ts":"2026-07-07T21:00:00+10:00","queryId":"q1","turnId":"t2","stopReason":"end_turn"}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-8a.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-8a.jsonl new file mode 100644 index 00000000..874f574d --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-8a.jsonl @@ -0,0 +1,2 @@ +{"subject":"conv.v2.conv-abc.requests.say","message":{"ts":"2026-07-07T21:00:00+10:00","from":{"kind":"human","userId":"stephen"},"text":"what does this show?","attachments":[{"type":"image","source":{"type":"object","id":"att-7c9e…","bucket":"attach","mediaType":"image/png","size":48213}}],"precondition":{"tip":"m4"}},"reply":{"accepted":true,"id":"q9"}} +{"subject":"conv.v2.conv-abc.changes.message","message":{"ts":"2026-07-07T21:00:00+10:00","id":"m5","queryId":"q9","turnId":"t3","role":"user","from":{"kind":"human","userId":"stephen"},"content":[{"type":"image","source":{"type":"object","id":"att-7c9e…","bucket":"attach","mediaType":"image/png","size":48213}},{"type":"text","text":"what does this show?"}]}} diff --git a/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-8b.jsonl b/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-8b.jsonl new file mode 100644 index 00000000..7fc738dc --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/fixtures/v2/scenario-8b.jsonl @@ -0,0 +1 @@ +{"subject":"conv.v2.conv-abc.requests.say","message":{"ts":"2026-07-07T21:00:00+10:00","from":{"kind":"human","userId":"stephen"},"text":"what does this show?","attachments":[{"type":"image","source":{"type":"object","id":"att-7c9e…","mediaType":"image/png","size":48213}}],"precondition":{"tip":"m4"}},"reply":{"rejected":true,"reason":"attachment_unavailable"}} diff --git a/apps/claude-sdk-cli/test/spec/schemas/agent.reply.schema.json b/apps/claude-sdk-cli/test/spec/schemas/agent.reply.schema.json index 17e27a10..ae3e7de8 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/agent.reply.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/agent.reply.schema.json @@ -21,6 +21,9 @@ }, "reason": { "type": "string" + }, + "detail": { + "type": "string" } }, "required": ["rejected", "reason"], diff --git a/apps/claude-sdk-cli/test/spec/schemas/conv.changes.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.changes.schema.json index dbdf133a..c2f8836e 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/conv.changes.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.changes.schema.json @@ -13,6 +13,9 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" }, + "instanceId": { + "type": "string" + }, "id": { "type": "string" }, @@ -68,7 +71,7 @@ } } }, - "required": ["type", "ts", "id", "queryId", "turnId", "role", "from", "content"], + "required": ["type", "ts", "id", "queryId", "turnId", "role", "content"], "additionalProperties": {} }, { @@ -83,6 +86,9 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" }, + "instanceId": { + "type": "string" + }, "messageId": { "type": "string" }, @@ -115,6 +121,9 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" }, + "instanceId": { + "type": "string" + }, "to": { "type": "string" } diff --git a/apps/claude-sdk-cli/test/spec/schemas/conv.deltas.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.deltas.schema.json index 0f0198d4..ca1a8632 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/conv.deltas.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.deltas.schema.json @@ -1,15 +1,41 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "delta" + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delta" + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": {} }, - "text": { - "type": "string" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "block" + }, + "blockType": { + "anyOf": [ + { + "type": "string", + "enum": ["thinking", "text", "tool_use"] + }, + { + "type": "string" + } + ] + } + }, + "required": ["type", "blockType"], + "additionalProperties": {} } - }, - "required": ["type", "text"], - "additionalProperties": {} + ] } diff --git a/apps/claude-sdk-cli/test/spec/schemas/conv.requests.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.requests.schema.json index ac8356ae..e5dbbaa9 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/conv.requests.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.requests.schema.json @@ -37,18 +37,59 @@ "text": { "type": "string" }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mediaType": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": ["type", "id"], + "additionalProperties": {} + } + }, + "required": ["type", "source"], + "additionalProperties": {} + } + }, "precondition": { "type": "object", "properties": { "tip": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": ["tip"], "additionalProperties": {} } }, - "required": ["type", "ts", "from", "text"], + "required": ["type", "ts", "from", "text", "precondition"], "additionalProperties": {} }, { diff --git a/apps/claude-sdk-cli/test/spec/schemas/conv.v2.attachment.attached.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.attachment.attached.schema.json new file mode 100644 index 00000000..495b9efc --- /dev/null +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.attachment.attached.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "ts": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "instanceId": { + "type": "string" + }, + "world": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "tip": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "intervalS": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["ts", "instanceId"], + "additionalProperties": {} +} diff --git a/apps/claude-sdk-cli/test/spec/schemas/agent.telemetry.detached.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.attachment.detached.schema.json similarity index 88% rename from apps/claude-sdk-cli/test/spec/schemas/agent.telemetry.detached.schema.json rename to apps/claude-sdk-cli/test/spec/schemas/conv.v2.attachment.detached.schema.json index 1adbc725..777f9945 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/agent.telemetry.detached.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.attachment.detached.schema.json @@ -10,10 +10,10 @@ "instanceId": { "type": "string" }, - "conversationId": { + "world": { "type": "string" } }, - "required": ["ts", "instanceId", "conversationId"], + "required": ["ts", "instanceId"], "additionalProperties": {} } diff --git a/apps/claude-sdk-cli/test/spec/schemas/agent.telemetry.attached.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.attachment.moved.schema.json similarity index 89% rename from apps/claude-sdk-cli/test/spec/schemas/agent.telemetry.attached.schema.json rename to apps/claude-sdk-cli/test/spec/schemas/conv.v2.attachment.moved.schema.json index 1c1c4eec..2729aa32 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/agent.telemetry.attached.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.attachment.moved.schema.json @@ -10,13 +10,13 @@ "instanceId": { "type": "string" }, - "conversationId": { + "world": { "type": "string" }, "cwd": { "type": "string" } }, - "required": ["ts", "instanceId", "conversationId"], + "required": ["ts", "instanceId", "cwd"], "additionalProperties": {} } diff --git a/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.message.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.message.schema.json index 78381c0a..d6faabd2 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.message.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.message.schema.json @@ -7,6 +7,9 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" }, + "instanceId": { + "type": "string" + }, "id": { "type": "string" }, @@ -62,6 +65,6 @@ } } }, - "required": ["ts", "id", "queryId", "turnId", "role", "from", "content"], + "required": ["ts", "id", "queryId", "turnId", "role", "content"], "additionalProperties": {} } diff --git a/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.query.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.query.schema.json index d4509ce9..66487125 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.query.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.query.schema.json @@ -7,6 +7,9 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" }, + "instanceId": { + "type": "string" + }, "queryId": { "type": "string" }, diff --git a/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.revision.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.revision.schema.json index db92eb34..2cc7f900 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.revision.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.revision.schema.json @@ -7,6 +7,9 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" }, + "instanceId": { + "type": "string" + }, "messageId": { "type": "string" }, diff --git a/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.tip.moved.schema.json b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.tip.moved.schema.json index 3292c680..ed79568b 100644 --- a/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.tip.moved.schema.json +++ b/apps/claude-sdk-cli/test/spec/schemas/conv.v2.changes.tip.moved.schema.json @@ -7,6 +7,9 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" }, + "instanceId": { + "type": "string" + }, "to": { "type": "string" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43a9a0de..6b07db00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@nats-io/nats-core': specifier: ^3.4.0 version: 3.4.0 + '@nats-io/obj': + specifier: ^3.4.0 + version: 3.4.0 '@nats-io/transport-node': specifier: ^3.4.0 version: 3.4.0 @@ -1457,6 +1460,9 @@ packages: resolution: {integrity: sha512-QbXZDrxmYlrn5AD06gYcUTmEHwxn96HBQMIk7XTjDlEcx6FPzVBBPjp4AMRh1lEv6B4iJ6Xb/Uz61JugPXFRQg==} engines: {node: '>= 22.x'} + '@nats-io/obj@3.4.0': + resolution: {integrity: sha512-zQRyrbxVtapKtqlxcA7qiMBN5aoo1aqSLNHyTZY9d6xBmBAYsw7sfYfSdHSguiR7GXtEjFgFnynTcv7Rw9MIrg==} + '@nats-io/transport-node@3.4.0': resolution: {integrity: sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==} engines: {node: '>= 18.0.0'} @@ -2744,6 +2750,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-sha256@0.11.1: + resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -4220,6 +4229,12 @@ snapshots: '@nats-io/nuid@3.0.0': {} + '@nats-io/obj@3.4.0': + dependencies: + '@nats-io/jetstream': 3.4.0 + '@nats-io/nats-core': 3.4.0 + js-sha256: 0.11.1 + '@nats-io/transport-node@3.4.0': dependencies: '@nats-io/nats-core': 3.4.0 @@ -4711,7 +4726,7 @@ snapshots: istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 magicast: 0.5.3 - obug: 2.1.3 + obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5)(yaml@2.9.0)) @@ -5206,6 +5221,8 @@ snapshots: joycon@3.1.1: {} + js-sha256@0.11.1: {} + js-tokens@10.0.0: {} js-yaml@4.3.0: @@ -5828,8 +5845,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyrainbow@3.1.0: {}