anthropics / anthropics/claude-agent-sdk-typescript
Resume injects synthetic "Continue from where you left off." / "No response requested." entries for outputFormat json_schema sessions
- Dominant language
- Shell
- Stars
- 1.8k
- Forks
- 226
- PR merge metrics
- No merged PRs in 30d
Description
### Summary
When a session uses `outputFormat: { type: 'json_schema' }`, every turn ends on the `StructuredOutput` `tool_result`. The resume classifier treats a trailing `tool_result` as an **interrupted turn**, so each resume injects two synthetic entries into the transcript:
- a user entry, `isMeta: true` — `"Continue from where you left off."`
- an assistant entry, `model: ''` — `"No response requested."`
These are persisted, and they accumulate: after N resumes the transcript carries N fabricated exchanges, which are replayed to the model on every subsequent turn.
This appears to be an oversight rather than intended behaviour, because the SDK's own types already describe this shape as **completed**. From the `resumeDropsTurn` JSDoc in `sdk.d.ts`:
> End-turn tool sessions (`outputFormat: {type: 'json_schema'}`, or any MCP tool using `_meta['claude/endTurn']`): a completed turn there ends on a successful tool_result carrier — with no trailing assistant message
The resume classifier doesn't appear to consult that: it accepts a trailing `tool_result` as complete only when the producing tool is one of three hardcoded names or is listed in `CLAUDE_CODE_TERMINAL_MCP_TOOLS`. `StructuredOutput` is in neither set, so the completed-turn shape the docs describe is classified as interrupted.
### Reproduction
Public API only, no custom CLI flags. Verified on **0.3.251** (also reproduces on 0.3.234).
```ts
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { SessionStore, SessionStoreEntry } from '@anthropic-ai/claude-agent-sdk';
const entries: SessionStoreEntry[] = [];
const store: SessionStore = {
async append(_key, batch) { entries.push(...batch); },
async load() { return entries.length ? entries : null; },
};
const outputFormat = {
type: 'json_schema' as const,
schema: {
type: 'object',
properties: { answer: { type: 'string' } },
required: ['answer'],
additionalProperties: false,
},
};
let sessionId: string | undefined;
for await (const m of query({
prompt: 'Say hello.',
options: { sessionStore: store, outputFormat },
})) {
if (m.type === 'result') sessionId = m.session_id;
}
for (const prompt of ['Say goodbye.', 'Say hello again.']) {
for await (const _ of query({
prompt,
options: { sessionStore: store, outputFormat, resume: sessionId },
})) { /* drain */ }
}
const json = JSON.stringify(entries);
console.log('entries:', entries.length);
console.log('"Continue from where you left off.":',
(json.match(/Continue from where you left off/g) ?? []).length);
console.log('"No response requested.":',
(json.match(/No response requested/g) ?? []).length);
```
### Actual
```
entries: 46
"Continue from where you left off.": 2
"No response requested.": 2
```
Two resumes, two fabricated pairs — one per resume.
### Expected
Zero. A turn that completed on the `StructuredOutput` carrier is not interrupted, so nothing should be injected on resume.
### Impact
We found this in a production service that uses `outputFormat` for every turn. Long-lived sessions had accumulated one pair per resume, so the model was reading a meaningful fraction of fabricated conversation alongside the real one — including a synthetic **user** message, which is an instruction the model can act on rather than inert text.
### Notes for anyone hitting this
Setting `CLAUDE_CODE_TERMINAL_MCP_TOOLS=StructuredOutput` suppresses the fabricated **user** message. It does not suppress `"No response requested."`, which is spliced by a separate rule that isn't gated on the interrupted-turn classification — it fires whenever the last non-system entry is a user-type entry, and a `tool_result` is one.
That variable also appears to be undocumented (not in the env-vars reference or `sdk.d.ts`), so we'd rather not depend on it long-term — hence this report.
Two things that make working around it downstream unattractive, in case they're useful context for prioritising:
1. **Removing the entries from a `SessionStore` breaks the transcript.** Entries are linked by `parentUuid` and the chain is walked on resume — and the real next user message names the synthetic assistant as its parent:
```
assistant uuid=94ca7ec5 parent=f27d44ba "No response requested."
user uuid=f20764b6 parent=94ca7ec5 <- the real next user message
```
Filtering without re-linking orphans the chain and silently truncates earlier history. Since a resumed turn doesn't re-send history, there's no fallback.
2. **Filtering in `load()` contradicts the documented contract** — "`load` must return entries that are deep-equal to what was appended" ([session-storage](https://code.claude.com/docs/en/agent-sdk/session-storage)).
So there doesn't seem to be a supported way to avoid this from the adapter side.
### Environment
- `@anthropic-ai/claude-agent-sdk` 0.3.251 (also 0.3.234)
- Node 22, macOS (darwin-arm64)
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.