+
+
+ {t('modelReasoning')}
+
+
+
+
+ { write(on ? initialEntries : undefined) }}
+ />
+
+ {entries === undefined
+ ? null
+ : (
+ <>
+ {entries.length > 0
+ ? (
+
+ {t('modelReasoningLevel')}
+ {t('modelReasoningValue')}
+
+ )
+ : null}
+ {entries.map(([level, wire], index) => (
+ // A row is the entry at this position of the stored dict: changing its
+ // level edits that entry in place and rows keep no state of their own,
+ // so the position is the row's identity. Keying by level would remount
+ // the select on every change and drop keyboard focus.
+
+
+ { editValue(entries, index, level, event.target.value) }}
+ onBlur={() => { leaveValue(entries, index, level, wire) }}
+ />
+
+
+ ))}
+ {refused
+ ?
{t('modelReasoningValueRequired')}
+ : missingLevel ?
{t('modelReasoningNeedsLevel')}
: null}
+
+ >
+ )}
+
+ )
+}
diff --git a/packages/client/ui-settings-models/src/client/ModelRow.tsx b/packages/client/ui-settings-models/src/client/ModelRow.tsx
index c6729ab0be44..1e765e3d4866 100644
--- a/packages/client/ui-settings-models/src/client/ModelRow.tsx
+++ b/packages/client/ui-settings-models/src/client/ModelRow.tsx
@@ -5,6 +5,7 @@ import { IconChevronDownOutline14, IconChevronRightOutline14, IconTrashOutline16
import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
import type { ModelsKey } from './locales.ts'
import { ModelInputTypes } from './ModelInputTypes.tsx'
+import { ModelReasoningEfforts } from './ModelReasoningEfforts.tsx'
import styles from './ModelsSection.module.css'
/** A capacity's editable text and adapter-specific inherited hint. */
@@ -22,12 +23,15 @@ interface ModelRowProps {
inputField: 'inputModalities' | 'input'
inputFallback?: readonly string[] | undefined
inputLoading?: boolean
+ /** Levels the row's `reasoningEfforts` may declare; absent or empty shows no reasoning control. */
+ reasoningLevels?: readonly string[] | undefined
expanded: boolean
disabled: boolean
t: (key: ModelsKey) => string
contextWindow: CapacityInput
maxTokens: CapacityInput
- onFieldChange: (field: 'id' | 'name', value: string | undefined) => void
+ onIdChange: (value: string) => void
+ onNameChange: (value: string | undefined) => void
onIdBlur?: (value: string) => void
onChange: (model: DeepSeekModelDraft) => void
onToggle: () => void
@@ -55,7 +59,8 @@ export function ModelRow(props: ModelRowProps): ReactNode {
disabled={disabled}
onChange={(event) => {
const value = event.target.value
- props.onFieldChange(field, field === 'name' && value === '' ? undefined : value)
+ if (field === 'id') props.onIdChange(value)
+ else props.onNameChange(value === '' ? undefined : value)
}}
onBlur={field === 'id' ? event => props.onIdBlur?.(event.target.value) : undefined}
/>
@@ -104,6 +109,13 @@ export function ModelRow(props: ModelRowProps): ReactNode {
model={model} field={props.inputField} position={position}
fallback={props.inputFallback} disabled={disabled || props.inputLoading === true} t={t} onChange={props.onChange}
/>
+ {props.reasoningLevels !== undefined && props.reasoningLevels.length > 0
+ ? (
+
@@ -492,6 +494,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
row.entry.provider)}
protocols={protocols}
+ reasoningLevels={reasoningLevels}
/* v8 ignore next -- the card only opens from a button disabled without this namespace */
revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0}
operations={operations}
diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx
index c3ca2719de19..79270360b7bf 100644
--- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx
+++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx
@@ -30,10 +30,13 @@ import type { JsonValue } from '@mutantcat/dsh-util-values'
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
+import type { DeepSeekModelsValidationFailure } from './DeepSeekModelsEditor.tsx'
import { apiKeyFailure } from './apiKey.ts'
import { EditorFooter } from './EditorFooter.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
-import { deriveKeyRef, protocolChoices } from './store.ts'
+import { validateReasoningEfforts } from './ModelReasoningEfforts.tsx'
+import type { ReasoningEffortsFailure } from './ModelReasoningEfforts.tsx'
+import { deriveKeyRef, protocolChoices, reasoningLevelChoices } from './store.ts'
import type { ModelsOperations } from './operations.ts'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import type { en } from './locales.ts'
@@ -183,6 +186,19 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
[layout, namespace, schema],
)
+ // The model list is validated by the same per-row checker for both families,
+ // so a bad row is named by its position rather than by a blanket message.
+ // Only a hand-declared pi-ai route's rows edit reasoning levels — the one
+ // model list with no installed catalog behind it — so only there are the
+ // levels read and the declarations checked.
+ const reasoningLevels = useMemo(
+ () => layout === 'pi-ai' && props.declared === true ? reasoningLevelChoices(namespace, schema) : [],
+ [layout, props.declared, namespace, schema],
+ )
+ const editsReasoning = reasoningLevels.length > 0
+ const validateModels = (value: unknown): DeepSeekModelsValidationFailure | ReasoningEffortsFailure | undefined =>
+ validateDeepSeekModels(value) ?? (editsReasoning ? validateReasoningEfforts(value) : undefined)
+
useEffect(() => {
let stale = false
setKeyState(undefined)
@@ -210,9 +226,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
: schema.setPath(current, [key], value))
}
- // The model list is validated by the same per-row checker for both families,
- // so a bad row is named by its position rather than by a blanket message.
- const modelFailure = validateDeepSeekModels(schema.getPath(draft, ['models']))
+ const modelFailure = validateModels(schema.getPath(draft, ['models']))
const keyFailure = apiKeyFailure(keyDraft)
// What a probe or a write must carry: the typed key with paste whitespace
// removed. A blank field yields an empty string, which both call sites read
@@ -256,7 +270,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
// with a bad row; it stays because the schema check below would refuse
// the write with a message naming a path instead of the row, and because
// nothing but this function decides what is written.
- const failure = validateDeepSeekModels(schema.getPath(next, ['models']))
+ const failure = validateModels(schema.getPath(next, ['models']))
/* v8 ignore next 3 -- unreachable from the card: the same failure disables submit */
if (failure !== undefined) {
return `${t('model')} ${String(failure.index + 1)}: ${t(failure.key)}`
@@ -468,6 +482,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
0 && IMAGE_ID_PATTERNS.some(pattern => pattern.test(id))
+}
+
+/**
+ * The declaration an id pre-selects, for a caller adopting a model rather than
+ * editing an existing row.
+ * @param modelId - the id about to be written.
+ * @returns text plus image, or `undefined` when the id names no such family.
+ */
+export function imageInputFor(modelId: string): readonly string[] | undefined {
+ return knownImageModel(modelId) ? [...IMAGE_INPUT] : undefined
+}
+
+/** A stored declaration read as a modality list, or `undefined` when absent or empty. */
+function declaredInput(value: unknown): readonly string[] | undefined {
+ if (!Array.isArray(value)) return undefined
+ const modalities = value.filter((entry): entry is string => typeof entry === 'string')
+ return modalities.length === 0 ? undefined : modalities
+}
+
+/** Whether two declarations name the same modalities in the same order. */
+function sameInput(left: readonly string[], right: readonly string[]): boolean {
+ return left.length === right.length && left.every((modality, at) => modality === right[at])
+}
+
+/**
+ * The declaration an id edit materializes for one row, as the fragment its
+ * editor merges into that row.
+ *
+ * A declaration the user composed survives an id edit; one this rule wrote,
+ * and a row that declared nothing at all, follow the new id: it gains Image
+ * when the new id names an image-capable family, and loses the declaration
+ * this rule wrote when it names no such family. Removal is the fragment's
+ * `undefined` value, which both editors drop from the row rather than store.
+ *
+ * @param options - the adapter's declaration field and the row's two ids.
+ * @returns the field to merge: a modalities array, or `undefined` to clear it.
+ */
+export function idInputPatch(options: {
+ /** Adapter-owned declaration field: `inputModalities` for DeepSeek, `input` for pi-ai. */
+ field: 'input' | 'inputModalities'
+ /** The row's id before this edit. */
+ previousId: string
+ /** The id this edit writes. */
+ nextId: string
+ /** The row's stored declaration, if it has one. */
+ declared: unknown
+}): Record {
+ const declared = declaredInput(options.declared)
+ const wroteIt = declared !== undefined
+ && knownImageModel(options.previousId)
+ && sameInput(declared, IMAGE_INPUT)
+ if (declared !== undefined && !wroteIt) return {}
+ const preselect = imageInputFor(options.nextId)
+ if (preselect !== undefined) return { [options.field]: preselect }
+ return wroteIt ? { [options.field]: undefined } : {}
+}
diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts
index 3271cf2761eb..bf6d709d8794 100644
--- a/packages/client/ui-settings-models/src/client/locales.ts
+++ b/packages/client/ui-settings-models/src/client/locales.ts
@@ -54,6 +54,16 @@ export const en = {
modelInputTypes: 'Input types',
modelInputText: 'Text',
modelInputImage: 'Image',
+ modelReasoning: 'Reasoning levels',
+ modelReasoningLevel: 'Level',
+ modelReasoningValue: 'API value',
+ modelReasoningHelp: 'About reasoning levels',
+ modelReasoningHelpText: 'The API value is sent in the request\'s reasoning-effort field, such as OpenAI\'s reasoning_effort. It defaults to the level name; where the model provider\'s API documentation specifies a different value, follow it. Off turns reasoning off and needs no value; a value already written in the configuration is kept and sent.',
+ modelReasoningNeedsLevel: 'Add at least one level besides Off, or turn reasoning levels off.',
+ modelReasoningValueRequired: 'Every level besides Off needs an API value, and no value can be empty.',
+ modelReasoningAdd: 'Add level',
+ modelReasoningRemove: 'Delete level',
+ modelReasoningOffValuePlaceholder: 'Turns reasoning off',
addModel: 'Add model',
removeModel: 'Delete model',
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
@@ -160,6 +170,16 @@ export const zh: { [Key in keyof typeof en]: string } = {
modelInputTypes: '输入类型',
modelInputText: '文本',
modelInputImage: '图片',
+ modelReasoning: '推理等级',
+ modelReasoningLevel: '档位',
+ modelReasoningValue: 'API 参数值',
+ modelReasoningHelp: '推理等级说明',
+ modelReasoningHelpText: 'API 参数值会写入请求的推理强度字段(如 OpenAI 的 reasoning_effort),默认与档位名相同;如有不同,以模型提供商的 API 文档为准。Off 表示关闭思考,不需要填写参数值;配置中已写入的值会保留并发送。',
+ modelReasoningNeedsLevel: '至少添加一个 Off 以外的档位;不需要思考请关闭推理等级。',
+ modelReasoningValueRequired: 'Off 以外的档位都需要填写 API 参数值,且参数值不能为空字符串。',
+ modelReasoningAdd: '添加档位',
+ modelReasoningRemove: '删除档位',
+ modelReasoningOffValuePlaceholder: '关闭思考',
addModel: '添加模型',
removeModel: '删除模型',
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts
index f57477968828..aa56bc5c8bdd 100644
--- a/packages/client/ui-settings-models/src/client/store.ts
+++ b/packages/client/ui-settings-models/src/client/store.ts
@@ -134,6 +134,28 @@ export function protocolChoices(
return list.list.map(entry => entry.value).filter((value): value is string => typeof value === 'string')
}
+/**
+ * The reasoning levels a hand-declared route's models may declare, read out of
+ * the owning namespace's own schema for the same reason as
+ * {@link protocolChoices}: the levels the page offers and the keys the adapter
+ * accepts in `reasoningEfforts` come from the same `Config`.
+ * @param namespace - the namespace view whose schema declares the profile shape.
+ * @param schema - settings schema operations.
+ * @returns the levels in the schema's escalation order, or an empty list when the schema has none.
+ */
+export function reasoningLevelChoices(
+ namespace: SettingsNamespaceView | undefined,
+ schema: SettingsSchemaOperations,
+): string[] {
+ if (namespace === undefined) return []
+ const path = ['providers', PROBE_ROUTE, 'models', '0', 'reasoningEfforts']
+ const node = schema.nodeAtPath(schema.rehydrate(namespace.schema), path)
+ // `false | { [level]: value }`: the dict branch's key schema lists the levels.
+ const branches = (node as { list?: readonly { type?: string; sKey?: unknown }[] } | undefined)?.list ?? []
+ const keys = branches.find(branch => branch.type === 'dict')?.sKey as { list?: readonly { value?: unknown }[] } | undefined
+ return (keys?.list ?? []).map(entry => entry.value).filter((value): value is string => typeof value === 'string')
+}
+
/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
function apiKeyEnvOf(
namespace: SettingsNamespaceView | undefined,
diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx
index 2de3855c4009..3cb8064045e6 100644
--- a/packages/client/ui-settings-models/tests/components.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx
@@ -57,6 +57,8 @@ const DeepSeekConfig = Schema.object({
baseURL: Schema.string().pattern(/^https:\/\//),
reasoningEffort: Schema.union(['off', 'low', 'high', 'max']),
defaultContextWindow: Schema.number().step(1).min(1),
+ // The route-wide input fallback the editor hands undeclared models.
+ defaultInput: Schema.array(Schema.union(['text', 'image'])).default(['text', 'image']),
models: Schema.array(Schema.object({
id: Schema.string().required(),
name: Schema.string(),
@@ -711,6 +713,35 @@ describe('ModelsSection', () => {
]])
})
+ it('marks an undeclared model input from the route default input', async () => {
+ const namespace: SettingsNamespaceView = {
+ ...wireNamespaces()[0]!,
+ value: {
+ apiKeyEnv: 'DEEPSEEK_API_KEY',
+ defaultInput: ['text', 'image'],
+ models: DEFAULT_DEEPSEEK_MODELS,
+ },
+ }
+ const { face } = scriptedFace()
+ const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
+ render()
+ fireEvent.click(screen.getByText(en.customized))
+ fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} 1`))
+
+ const image = screen.getByLabelText(en.modelInputImage)
+ expect(image.checked).toBe(true)
+ })
+
it('rejects duplicate DeepSeek model ids before writing', async () => {
const { mutate } = await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
@@ -1065,6 +1096,31 @@ describe('ModelsSection', () => {
expect(screen.getAllByLabelText(en.modelInputImage)[0]?.checked).toBe(false)
})
+ it('pre-selects image input when an added model id names a vision family', () => {
+ const onChange = vi.fn()
+ const props = {
+ overridden: false,
+ defaultContextWindow: undefined,
+ defaultMaxTokens: undefined,
+ defaultInput: undefined,
+ t,
+ disabled: false,
+ onChange,
+ onReset: vi.fn(),
+ }
+ const { rerender } = render()
+ fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'deepseek-vl2' } })
+ expect(onChange).toHaveBeenLastCalledWith([{ id: 'deepseek-vl2', inputModalities: ['text', 'image'] }])
+
+ rerender()
+ expandRow(1)
+ expect(screen.getByLabelText(en.modelInputImage).checked).toBe(true)
+
+ // Renaming to an id this rule does not recognize drops what it wrote.
+ fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+ expect(onChange).toHaveBeenLastCalledWith([{ id: 'acme-large' }])
+ })
+
it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
diff --git a/packages/client/ui-settings-models/tests/image-capability.client.spec.ts b/packages/client/ui-settings-models/tests/image-capability.client.spec.ts
new file mode 100644
index 000000000000..bbec80b44e11
--- /dev/null
+++ b/packages/client/ui-settings-models/tests/image-capability.client.spec.ts
@@ -0,0 +1,98 @@
+/** Id-based image pre-selection: which model names it recognizes and what an id edit writes. */
+import { describe, expect, it } from 'vitest'
+import { idInputPatch, imageInputFor, knownImageModel } from '../src/client/image-capability.ts'
+
+describe('knownImageModel', () => {
+ it('recognizes vision families with and without a vendor prefix or alias', () => {
+ for (const id of [
+ 'gpt-4o',
+ 'openai/gpt-4.1-mini',
+ 'gpt-4-turbo',
+ 'gpt-4-vision-preview',
+ 'gpt-5-mini',
+ 'o3-2025-04-16',
+ 'openai/o4-mini',
+ 'claude-3-5-sonnet',
+ 'anthropic/claude-sonnet-4-5',
+ 'gemini-2.5-flash',
+ 'gemma-3-27b',
+ 'qwen2.5-vl-72b',
+ 'deepseek-vl2',
+ 'glm-4v',
+ 'step-1v',
+ 'llava-1.6',
+ 'pixtral-large',
+ 'internvl2',
+ 'llama-4-scout',
+ 'grok-2-vision',
+ 'some-vendor-omni',
+ 'acme-multimodal-chat',
+ ]) {
+ expect(knownImageModel(id)).toBe(true)
+ }
+ })
+
+ it('leaves text-only families and unknown names alone', () => {
+ // `o1-mini`, `o1-preview`, `o3-mini`, and plain `gpt-4` take text only, and
+ // an embedding or unknown alias names no image capability at all.
+ for (const id of ['', ' ', 'deepseek-v4-pro', 'gpt-4', 'o1-mini', 'o1-preview', 'o3-mini',
+ 'claude-2.1', 'deepseek-embedding', 'my-vllm', 'acme-large']) {
+ expect(knownImageModel(id)).toBe(false)
+ }
+ })
+})
+
+describe('imageInputFor', () => {
+ it('answers text plus image for a recognized id and nothing otherwise', () => {
+ expect(imageInputFor('gpt-4o')).toEqual(['text', 'image'])
+ expect(imageInputFor('acme-large')).toBeUndefined()
+ })
+})
+
+describe('idInputPatch', () => {
+ const field = 'input' as const
+
+ it('pre-selects image when a row declares nothing and the id names a vision family', () => {
+ expect(idInputPatch({ field, previousId: '', nextId: 'gpt-4o', declared: [] }))
+ .toEqual({ input: ['text', 'image'] })
+ expect(idInputPatch({ field, previousId: '', nextId: 'qwen-vl-max', declared: undefined }))
+ .toEqual({ input: ['text', 'image'] })
+ })
+
+ it('keeps a declaration the user composed, including a text-only override', () => {
+ expect(idInputPatch({ field, previousId: 'gpt-4o', nextId: 'claude-3-5-sonnet', declared: ['text'] }))
+ .toEqual({})
+ // Image plus a modality this rule never writes is not the rule's to replace.
+ expect(idInputPatch({ field, previousId: 'gpt-4o', nextId: 'acme-large', declared: ['image'] }))
+ .toEqual({})
+ // Nor is a text-only declaration on a family this rule does not recognize.
+ expect(idInputPatch({ field, previousId: 'acme-large', nextId: 'acme-small', declared: ['text', 'image'] }))
+ .toEqual({})
+ })
+
+ it('drops the declaration it wrote when the new id names no vision family', () => {
+ expect(idInputPatch({ field, previousId: 'gpt-4o', nextId: 'acme-large', declared: ['text', 'image'] }))
+ .toEqual({ input: undefined })
+ // The same modalities in another order are not this rule's declaration.
+ expect(idInputPatch({ field, previousId: 'gpt-4o', nextId: 'acme-large', declared: ['image', 'text'] }))
+ .toEqual({})
+ expect(idInputPatch({ field, previousId: 'gpt-4o', nextId: 'acme-large', declared: ['text', 'image', 'image'] }))
+ .toEqual({})
+ })
+
+ it('leaves a row alone when neither id names a vision family', () => {
+ expect(idInputPatch({ field, previousId: 'acme-large', nextId: 'acme-small', declared: undefined }))
+ .toEqual({})
+ // A declaration holding no modality this build knows is no declaration.
+ expect(idInputPatch({ field, previousId: 'acme-large', nextId: 'acme-small', declared: ['text', 7] }))
+ .toEqual({})
+ expect(idInputPatch({ field, previousId: 'acme-large', nextId: 'qwen-vl-max', declared: [7] }))
+ .toEqual({ input: ['text', 'image'] })
+ })
+
+ it('writes the adapter-owned field of the editor that asks', () => {
+ expect(idInputPatch({
+ field: 'inputModalities', previousId: '', nextId: 'gemini-pro', declared: undefined,
+ })).toEqual({ inputModalities: ['text', 'image'] })
+ })
+})
diff --git a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx
index 60e7d7916591..a21adcb41663 100644
--- a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx
@@ -55,6 +55,22 @@ it('uses provider input defaults for a model absent from the installed catalog',
expect(onChange).toHaveBeenCalledWith([{ id: 'custom', input: ['text', 'image'] }])
})
+it('adopts image input for a fetched vision model the listing does not describe', async () => {
+ const onChange = vi.fn()
+ render( en[key]}
+ probe={{ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.example/v1' }}
+ operations={operations(() => Promise.resolve({
+ kind: 'found', models: [{ id: 'gpt-4o' }, { id: 'acme-text' }],
+ }))}
+ />)
+ fireEvent.click(screen.getByRole('button', { name: en.fetchModels }))
+ const picker = await screen.findByRole('dialog', { name: en.fetchTitle })
+ fireEvent.click(within(picker).getByRole('button', { name: en.fetchAdopt }))
+ // The listing named no modalities, so the vision id supplies the declaration.
+ expect(onChange).toHaveBeenCalledExactlyOnceWith([{ id: 'gpt-4o', input: ['text', 'image'] }, { id: 'acme-text' }])
+})
+
it('inherits catalog inputs once an incomplete draft has a model id', async () => {
const onChange = vi.fn()
const props = {
@@ -62,7 +78,7 @@ it('inherits catalog inputs once an incomplete draft has a model id', async () =
probe: { settingsNs: 'llm-pi-ai', provider: 'openai' },
disabled: false, t: (key: keyof typeof en) => en[key],
operations: operations(() => Promise.resolve({
- kind: 'found', models: [{ id: 'vision', inputModalities: ['text', 'image'] }],
+ kind: 'found', models: [{ id: 'acme-seer', inputModalities: ['text', 'image'] }],
})),
}
const { rerender } = render()
@@ -75,13 +91,48 @@ it('inherits catalog inputs once an incomplete draft has a model id', async () =
const id = screen.getByLabelText(`${en.modelId} 1`)
expect(id.value).toBe('')
- fireEvent.change(id, { target: { value: 'vision' } })
- expect(onChange).toHaveBeenCalledExactlyOnceWith([{ id: 'vision' }])
- rerender()
+ fireEvent.change(id, { target: { value: 'acme-seer' } })
+ expect(onChange).toHaveBeenCalledExactlyOnceWith([{ id: 'acme-seer' }])
+ rerender()
expect(image.checked).toBe(true)
expect(onChange).toHaveBeenCalledTimes(1)
})
+it('pre-selects image input for a known vision model id and drops it on a rename', async () => {
+ const onChange = vi.fn()
+ const props = {
+ onChange,
+ disabled: false, t: (key: keyof typeof en) => en[key],
+ operations: operations(() => Promise.resolve({ kind: 'found', models: [] })),
+ }
+ const { rerender } = render()
+ fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` }))
+ const id = screen.getByLabelText(`${en.modelId} 1`)
+
+ // The id alone names the capability, so the declaration is written with it.
+ fireEvent.change(id, { target: { value: 'gpt-4o' } })
+ expect(onChange).toHaveBeenLastCalledWith([{ id: 'gpt-4o', input: ['text', 'image'] }])
+ rerender()
+ expect(screen.getByRole('checkbox', { name: en.modelInputImage }).checked).toBe(true)
+
+ // Renaming away from the family removes the declaration this rule wrote.
+ fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+ expect(onChange).toHaveBeenLastCalledWith([{ id: 'acme-large' }])
+})
+
+it('keeps an id edit that a user composed before the rename', async () => {
+ const onChange = vi.fn()
+ render( en[key]} probe={{ settingsNs: 'llm-pi-ai' }}
+ operations={operations(() => Promise.resolve({ kind: 'found', models: [] }))}
+ />)
+ fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` }))
+ fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'claude-3-5-sonnet' } })
+ // A text-only override outlives the rename; only this rule's own declaration follows the id.
+ expect(onChange).toHaveBeenLastCalledWith([{ id: 'claude-3-5-sonnet', input: ['text'] }])
+})
+
it('restores inherited image input after a failed catalog read is retried manually', async () => {
const discover = vi.fn()
.mockResolvedValueOnce({ kind: 'refused', message: 'Catalog unavailable' })
diff --git a/packages/client/ui-settings-models/tests/model-reasoning-efforts.client.spec.tsx b/packages/client/ui-settings-models/tests/model-reasoning-efforts.client.spec.tsx
new file mode 100644
index 000000000000..fb8eb8ce50d9
--- /dev/null
+++ b/packages/client/ui-settings-models/tests/model-reasoning-efforts.client.spec.tsx
@@ -0,0 +1,303 @@
+// @vitest-environment jsdom
+/** Reasoning levels of hand-declared pi-ai models: echo, level rows, API values, refusals, and placement. */
+import { useState } from 'react'
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { DeepSeekModelDraft } from '../src/client/DeepSeekModelsEditor.tsx'
+import { validateDeepSeekModels } from '../src/client/DeepSeekModelsEditor.tsx'
+import { ModelListEditor } from '../src/client/ModelListEditor.tsx'
+import { ModelReasoningEfforts, validateReasoningEfforts } from '../src/client/ModelReasoningEfforts.tsx'
+import type { ModelsOperations } from '../src/client/operations.ts'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const t = (key: keyof typeof en): string => en[key]
+const DEFAULTS = { off: null, low: 'low', medium: 'medium', high: 'high' }
+/** The levels llm-pi-ai's schema lists today. */
+const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
+
+/** Render the controlled group over local state, reporting every write. */
+function renderGroup(initial: DeepSeekModelDraft, options: { disabled?: boolean; levels?: readonly string[] } = {}) {
+ const writes: DeepSeekModelDraft[] = []
+ function Harness() {
+ const [model, setModel] = useState(initial)
+ return (
+ { writes.push(next); setModel(next) }}
+ />
+ )
+ }
+ render()
+ return { writes, efforts: () => writes.at(-1)?.['reasoningEfforts'] }
+}
+
+const toggle = () => screen.getByRole('switch', { name: `${en.modelReasoning} 1` })
+const levels = () => screen.queryAllByRole('combobox').map(select => select.value)
+const select = (row: number) => screen.getByRole('combobox', { name: `${en.modelReasoningLevel} ${String(row)}` })
+const value = (level: string) => screen.getByRole('textbox', { name: `${level} ${en.modelReasoningValue}` })
+const remove = (level: string) => screen.getByRole('button', { name: `${en.modelReasoningRemove} ${level}` })
+const add = () => screen.getByRole('button', { name: en.modelReasoningAdd })
+const entriesOf = (efforts: unknown) => Object.entries(efforts as object)
+
+describe('ModelReasoningEfforts', () => {
+ it.each([
+ ['absent', { id: 'chat' }],
+ ['false', { id: 'chat', reasoningEfforts: false }],
+ ])('shows a model whose field is %s as switched off without writing', (_label, model) => {
+ const { writes } = renderGroup(model)
+ expect(toggle().getAttribute('aria-checked')).toBe('false')
+ expect(levels()).toEqual([])
+ expect(writes).toEqual([])
+ })
+
+ it('echoes stored levels in their stored order with their API values', () => {
+ renderGroup({ id: 'gateway', reasoningEfforts: { off: null, max: 'ultra', high: 'high' } })
+ expect(toggle().getAttribute('aria-checked')).toBe('true')
+ expect(screen.getByRole('button', { name: en.modelReasoningHelp })).toBeTruthy()
+ expect(levels()).toEqual(['off', 'max', 'high'])
+ expect(value('off').value).toBe('')
+ expect(value('off').placeholder).toBe(en.modelReasoningOffValuePlaceholder)
+ expect(value('off').disabled).toBe(true)
+ expect(value('max').value).toBe('ultra')
+ expect(value('high').value).toBe('high')
+ // Each select offers its own level and the levels no other row uses.
+ expect([...select(3).options].map(option => option.value)).toEqual(['minimal', 'low', 'medium', 'high', 'xhigh'])
+ expect(screen.queryByText(en.modelReasoningValueRequired)).toBeNull()
+ })
+
+ it('turns on with off, low, medium, and high while keeping other fields', () => {
+ const { writes } = renderGroup({ id: 'chat', contextWindow: 131072, compat: { supportsDeveloperRole: false } })
+ fireEvent.click(toggle())
+ expect(writes.at(-1)).toEqual({
+ id: 'chat', contextWindow: 131072, compat: { supportsDeveloperRole: false }, reasoningEfforts: DEFAULTS,
+ })
+ expect(levels()).toEqual(['off', 'low', 'medium', 'high'])
+ })
+
+ it('removes the field when switched off and starts from the defaults when switched back on', () => {
+ const { writes } = renderGroup({ id: 'chat', reasoningEfforts: { off: 'none', high: 'deep' } })
+ fireEvent.click(toggle())
+ expect(writes[0]).toEqual({ id: 'chat' })
+ fireEvent.click(toggle())
+ expect(writes[1]).toEqual({ id: 'chat', reasoningEfforts: DEFAULTS })
+ })
+
+ it('appends the next unused level above the highest row, then the lowest unused one', () => {
+ const { efforts } = renderGroup({ id: 'chat', reasoningEfforts: { low: 'low', max: 'max' } })
+ fireEvent.click(add())
+ expect(levels()).toEqual(['low', 'max', 'off'])
+ expect(entriesOf(efforts())).toEqual([['low', 'low'], ['max', 'max'], ['off', null]])
+ fireEvent.click(add())
+ expect(levels()).toEqual(['low', 'max', 'off', 'minimal'])
+ expect(entriesOf(efforts())).toEqual([['low', 'low'], ['max', 'max'], ['off', null], ['minimal', 'minimal']])
+ })
+
+ it('stops adding once every level has a row', () => {
+ renderGroup({ id: 'chat', reasoningEfforts: { off: null, minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh' } })
+ fireEvent.click(add())
+ expect(levels()).toHaveLength(7)
+ expect(add().disabled).toBe(true)
+ })
+
+ it('changes a level in place, carrying a custom value only between thinking levels', () => {
+ const { efforts } = renderGroup({ id: 'chat', reasoningEfforts: { low: 'low', high: 'deep', max: 'max' } })
+ fireEvent.change(select(1), { target: { value: 'xhigh' } })
+ expect(levels()).toEqual(['xhigh', 'high', 'max'])
+ expect(entriesOf(efforts())).toEqual([['xhigh', 'xhigh'], ['high', 'deep'], ['max', 'max']])
+ fireEvent.change(select(2), { target: { value: 'medium' } })
+ expect(entriesOf(efforts())).toEqual([['xhigh', 'xhigh'], ['medium', 'deep'], ['max', 'max']])
+ fireEvent.change(select(2), { target: { value: 'off' } })
+ expect(entriesOf(efforts())).toEqual([['xhigh', 'xhigh'], ['off', null], ['max', 'max']])
+ fireEvent.change(select(2), { target: { value: 'low' } })
+ expect(entriesOf(efforts())).toEqual([['xhigh', 'xhigh'], ['low', 'low'], ['max', 'max']])
+ })
+
+ it('edits API values, storing the level name for a cleared field and showing it once the field is left', () => {
+ const { efforts } = renderGroup({ id: 'gateway', reasoningEfforts: { off: 'none', high: 'high' } })
+ expect(value('off').value).toBe('none')
+ fireEvent.change(value('high'), { target: { value: ' deep ' } })
+ expect(efforts()).toEqual({ off: 'none', high: 'deep' })
+ fireEvent.change(value('high'), { target: { value: '' } })
+ expect(value('high').value).toBe('')
+ expect(efforts()).toEqual({ off: 'none', high: 'high' })
+ fireEvent.blur(value('high'))
+ expect(value('high').value).toBe('high')
+ })
+
+ it.each([
+ ['a level besides off without a value', { off: null, high: null }, 'high'],
+ ['an empty value', { off: null, high: '' }, 'high'],
+ ['an empty off value', { off: '', high: 'high' }, 'off'],
+ ])('flags %s that the adapter refuses', (_label, stored, level) => {
+ renderGroup({ id: 'chat', reasoningEfforts: stored })
+ expect(value(level).getAttribute('aria-invalid')).toBe('true')
+ expect(screen.getByText(en.modelReasoningValueRequired)).toBeTruthy()
+ })
+
+ it('repairs a refused value with the level name once its field is left', () => {
+ const { efforts } = renderGroup({ id: 'chat', reasoningEfforts: { off: null, high: null } })
+ expect(value('high').value).toBe('')
+ fireEvent.focus(value('high'))
+ fireEvent.blur(value('high'))
+ expect(efforts()).toEqual({ off: null, high: 'high' })
+ expect(value('high').getAttribute('aria-invalid')).toBe('false')
+ expect(screen.queryByText(en.modelReasoningValueRequired)).toBeNull()
+ })
+
+ it('leaves an accepted value alone when its field is left', () => {
+ const { writes } = renderGroup({ id: 'chat', reasoningEfforts: { high: 'deep' } })
+ fireEvent.blur(value('high'))
+ expect(writes).toEqual([])
+ })
+
+ it('shows a valueless field as switched on with no levels, which the adapter refuses', () => {
+ const { writes } = renderGroup({ id: 'chat', reasoningEfforts: null })
+ expect(toggle().getAttribute('aria-checked')).toBe('true')
+ expect(levels()).toEqual([])
+ expect(screen.getByText(en.modelReasoningNeedsLevel)).toBeTruthy()
+ fireEvent.click(add())
+ expect(writes.at(-1)).toEqual({ id: 'chat', reasoningEfforts: { off: null } })
+ })
+
+ it('deletes a row and requires a level besides off', () => {
+ const { efforts } = renderGroup({ id: 'chat', reasoningEfforts: { off: null, high: 'high' } })
+ expect(screen.queryByText(en.modelReasoningNeedsLevel)).toBeNull()
+ fireEvent.click(remove('high'))
+ expect(levels()).toEqual(['off'])
+ expect(efforts()).toEqual({ off: null })
+ expect(screen.getByText(en.modelReasoningNeedsLevel)).toBeTruthy()
+ fireEvent.click(remove('off'))
+ expect(levels()).toEqual([])
+ expect(efforts()).toEqual({})
+ })
+
+ it('offers the levels it is given, seeding only the defaults the list names', () => {
+ const { efforts } = renderGroup({ id: 'gateway' }, { levels: ['off', 'low', 'high', 'ultra'] })
+ fireEvent.click(toggle())
+ expect(entriesOf(efforts())).toEqual([['off', null], ['low', 'low'], ['high', 'high']])
+ expect([...select(3).options].map(option => option.value)).toEqual(['high', 'ultra'])
+ fireEvent.click(add())
+ expect(levels()).toEqual(['off', 'low', 'high', 'ultra'])
+ expect(add().disabled).toBe(true)
+ })
+
+ it('keeps a stored key the offered levels do not name', () => {
+ const { efforts } = renderGroup({ id: 'gateway', reasoningEfforts: { turbo: 'turbo-x', high: 'high' } })
+ expect(levels()).toEqual(['turbo', 'high'])
+ expect([...select(1).options].map(option => option.value)).toEqual(['turbo', 'off', 'minimal', 'low', 'medium', 'xhigh', 'max'])
+ fireEvent.change(value('high'), { target: { value: 'deep' } })
+ expect(entriesOf(efforts())).toEqual([['turbo', 'turbo-x'], ['high', 'deep']])
+ })
+
+ it('disables every control while read-only or saving', () => {
+ const { writes } = renderGroup({ id: 'chat', reasoningEfforts: { high: 'deep' } }, { disabled: true })
+ expect((toggle() as HTMLButtonElement).disabled).toBe(true)
+ expect(select(1).disabled).toBe(true)
+ expect(value('high').disabled).toBe(true)
+ expect(remove('high').disabled).toBe(true)
+ expect(add().disabled).toBe(true)
+ toggle().click()
+ remove('high').click()
+ add().click()
+ expect(writes).toEqual([])
+ })
+})
+
+describe('reasoning-level validation', () => {
+ it.each([
+ [{}],
+ [null],
+ [{ off: null }],
+ [{ off: 'none' }],
+ ])('refuses %j, which offers nothing beyond off', (stored) => {
+ expect(validateReasoningEfforts([{ id: 'ok' }, { id: 'chat', reasoningEfforts: stored }]))
+ .toEqual({ index: 1, key: 'modelReasoningNeedsLevel' })
+ })
+
+ it.each([
+ [{ high: null }],
+ [{ off: null, high: '' }],
+ [{ off: '', high: 'high' }],
+ ])('refuses %j, which holds a value the adapter rejects', (stored) => {
+ expect(validateReasoningEfforts([{ id: 'chat', reasoningEfforts: stored }]))
+ .toEqual({ index: 0, key: 'modelReasoningValueRequired' })
+ })
+
+ it.each([[undefined], [false], [{ high: 'high' }], [{ off: null, max: 'ultra' }]])('accepts %j', (stored) => {
+ expect(validateReasoningEfforts([{ id: 'chat', reasoningEfforts: stored }])).toBeUndefined()
+ })
+
+ it('accepts an inherited model list', () => {
+ expect(validateReasoningEfforts(undefined)).toBeUndefined()
+ })
+
+ it('stays out of the shared model checker that DeepSeek and catalog rows use', () => {
+ expect(validateDeepSeekModels([{ id: 'chat', reasoningEfforts: { off: null } }])).toBeUndefined()
+ })
+})
+
+describe('reasoning levels in the model list', () => {
+ const operations: ModelsOperations = {
+ discoverModels: () => Promise.resolve({ kind: 'found', models: [] }),
+ describeCredential: () => Promise.resolve(undefined),
+ storeCredential: vi.fn(),
+ removeCredential: vi.fn(),
+ writeSettings: vi.fn(),
+ }
+
+ /** Render the list over local state, reporting every write. */
+ function renderList(initial: DeepSeekModelDraft[]) {
+ const writes: DeepSeekModelDraft[][] = []
+ function Harness() {
+ const [models, setModels] = useState(initial)
+ return (
+ { writes.push(next); setModels(next) }} reasoningLevels={LEVELS}
+ probe={{ settingsNs: 'llm-pi-ai' }} operations={operations} disabled={false} t={t}
+ />
+ )
+ }
+ render()
+ return { writes }
+ }
+
+ it('edits a hand-declared route row', () => {
+ const { writes } = renderList([{ id: 'my-reasoner' }])
+ fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` }))
+ fireEvent.click(toggle())
+ expect(writes.at(-1)).toEqual([{ id: 'my-reasoner', reasoningEfforts: DEFAULTS }])
+ })
+
+ it('writes only the surviving model\'s own levels after the row above it is deleted', () => {
+ // Rows are keyed by position, so the survivor reuses the deleted model's editor instance.
+ const { writes } = renderList([{ id: 'a' }, { id: 'b' }])
+ fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` }))
+ fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 2` }))
+ const first = within(screen.getByRole('group', { name: `${en.modelReasoning} 1` }))
+ fireEvent.click(first.getByRole('switch'))
+ fireEvent.click(first.getByRole('button', { name: `${en.modelReasoningRemove} medium` }))
+ fireEvent.change(first.getByRole('textbox', { name: `high ${en.modelReasoningValue}` }), { target: { value: 'deep' } })
+ fireEvent.click(first.getByRole('switch'))
+ fireEvent.click(screen.getByRole('button', { name: `${en.removeModel} 1` }))
+ expect(writes.at(-1)).toEqual([{ id: 'b' }])
+
+ fireEvent.click(within(screen.getByRole('group', { name: `${en.modelReasoning} 1` })).getByRole('switch'))
+ expect(writes.at(-1)).toEqual([{ id: 'b', reasoningEfforts: DEFAULTS }])
+ })
+
+ it.each([
+ ['without a level list', undefined],
+ ['with an empty level list', []],
+ ])('shows no reasoning control %s, leaving catalog rows to the installed catalog', (_label, reasoningLevels) => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` }))
+ expect(screen.getByRole('group', { name: `${en.modelInputTypes} 1` })).toBeTruthy()
+ expect(screen.queryByRole('switch', { name: `${en.modelReasoning} 1` })).toBeNull()
+ })
+})
diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
index fa967bbdcfd4..cf050f7104c8 100644
--- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
@@ -11,7 +11,7 @@ import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/Mo
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
import { SettingsDescribeMirror } from '@mutantcat/dsh-client-ui-settings/src/client/settings-mirror.ts'
-import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts'
+import { ModelsSettingsStore, deriveKeyRef, protocolChoices, reasoningLevelChoices } from '../src/client/store.ts'
import { createModelsOperations } from '../src/client/operations.ts'
import type { ModelsOperations } from '../src/client/operations.ts'
import { en } from '../src/client/locales.ts'
@@ -22,6 +22,7 @@ afterEach(cleanup)
const t: ModelsSectionInjected['t'] = key => en[key]
const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages']
+const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
/** The pi-ai profile shape as the host serializes it, including the layer-1 fields. */
const PiAiConfig = Schema.object({
@@ -36,6 +37,13 @@ const PiAiConfig = Schema.object({
name: Schema.string(),
contextWindow: Schema.number(),
maxTokens: Schema.number(),
+ // The levels a hand-declared route's models may name, exactly as the
+ // adapter declares them: `false` or a dict whose key schema is the level
+ // union (the value schema stays unconstrained for this page).
+ reasoningEfforts: Schema.union([
+ Schema.const(false),
+ Schema.dict(Schema.union([Schema.string(), Schema.const(null)]), Schema.union(LEVELS)),
+ ]),
})),
reasoning: Schema.union(['off', 'high']),
})),
@@ -255,6 +263,20 @@ describe('protocolChoices', () => {
})
})
+describe('reasoningLevelChoices', () => {
+ it('reads the declared levels out of the namespace schema and nothing else', async () => {
+ const { namespace } = scriptedFace()
+ const levels = reasoningLevelChoices(namespace, settingsSchema)
+
+ expect(levels.length).toBeGreaterThan(0)
+ expect(new Set(levels).size).toBe(levels.length)
+ expect(reasoningLevelChoices(undefined, settingsSchema)).toEqual([])
+ const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as JsonValue }
+ expect(reasoningLevelChoices(plain, settingsSchema)).toEqual([])
+ await Promise.resolve()
+ })
+})
+
describe('model list editing', () => {
it('changes image input without rewriting a neighboring model declaration', async () => {
const neighbor = { id: 'vision', input: ['image'], name: 'Kept vision model' }
@@ -664,7 +686,7 @@ describe('endpoint interrogation', () => {
const scripted = scriptedFace()
render(
,
)
@@ -834,6 +856,7 @@ describe('hand-declared providers', () => {
({ ...block })),
- isError: node.isError,
- }],
+ toolCallId: node.callId,
+ content: node.content.map(block => ({ ...block })),
+ isError: node.isError,
},
...(node.error === undefined ? {} : { error: node.error }),
...(node.meta === undefined ? {} : { meta: node.meta }),
diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml
index 814b7fa5c623..5ca199d06662 100644
--- a/packages/client/ui-trajectory/README.i18n.yaml
+++ b/packages/client/ui-trajectory/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
-README.md: d48185cd913e71680208bb5c806cecbe8a6b703e
-README.zh.md: 8909de46cea2a14282195ea7c21b105783144f9d
+README.md: 7df4e6f314578fd9a7ea1880a9c2af2d84dc6e17
+README.zh.md: 60a079286e7970130ae5fb9643623746d1633073
diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md
index d48185cd913e..7df4e6f31457 100644
--- a/packages/client/ui-trajectory/README.md
+++ b/packages/client/ui-trajectory/README.md
@@ -94,6 +94,8 @@ None; this package neither assembles nor sends a provider request.
+- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row.
+
These limits define what the view can show while work is in flight; they are current package constraints.
diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md
index 8909de46cea2..60a079286e79 100644
--- a/packages/client/ui-trajectory/README.zh.md
+++ b/packages/client/ui-trajectory/README.zh.md
@@ -94,6 +94,8 @@ Trajectory 要求会话壳把 composer 作为浮层置于全高记录表上方
+- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。
+
这些限制定义工作仍在进行时视图能显示什么;它们是当前包约束。
diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json
index 1002dd9d2860..d73308fbdb9f 100644
--- a/packages/client/ui-trajectory/package.json
+++ b/packages/client/ui-trajectory/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-client-ui-trajectory",
"description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
index 6e17befee246..05d5263d675a 100644
--- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
+++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
@@ -905,12 +905,6 @@ function messageSourceLabel(source: unknown, t: TrajectoryTranslate): string {
const properties = source as Record
const kind = properties.kind
if (kind === 'user') return t('source.user')
- if (kind === 'plugin') {
- const plugin = properties.plugin
- return typeof plugin === 'string' && plugin !== ''
- ? t('source.pluginNamed', { plugin })
- : t('source.plugin')
- }
if (kind === 'goal') {
const round = properties.round
return typeof round === 'number' && round > 0
diff --git a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts
index 23efc3e817fc..0c797e6f7808 100644
--- a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts
+++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts
@@ -21,7 +21,7 @@ function checkpointId(
readonly plugin?: unknown
readonly compactionId?: unknown
}
- return source.kind === 'plugin' && source.plugin === 'compact'
+ return source.kind === 'compact-checkpoint'
&& typeof source.compactionId === 'string' && source.compactionId !== ''
? source.compactionId
: undefined
diff --git a/packages/client/ui-trajectory/src/client/trajectory-event-projection.ts b/packages/client/ui-trajectory/src/client/trajectory-event-projection.ts
index 552ec5c023a8..69074b684091 100644
--- a/packages/client/ui-trajectory/src/client/trajectory-event-projection.ts
+++ b/packages/client/ui-trajectory/src/client/trajectory-event-projection.ts
@@ -66,8 +66,6 @@ export function contextProducer(source: unknown): ContextProducerView {
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
case 'agent-instructions':
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
- case 'plugin':
- return { role: 'inject', label: readString(record, 'plugin') ?? kind }
case 'skill-invocation':
return { role: 'inject', label: readString(record, 'name') ?? kind }
default:
diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts
index 404935d10119..49d3db931b47 100644
--- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts
+++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts
@@ -136,9 +136,13 @@ const trajectoryInboxDefinition: ConversationNodeDefinition = {
const trajectoryMessageDefinition: ConversationNodeDefinition = {
kind: 'trajectory-input-message',
target: 'trajectory',
- match: event => event.type === 'user/message'
- ? { id: String(event.seq), role: 'start' }
- : null,
+ match: (event) => {
+ // Developer history is persisted for V4; presentation is intentionally deferred.
+ if (event.type === 'developer/message') throw new Error('Trajectory developer messages are not supported yet')
+ return event.type === 'user/message'
+ ? { id: String(event.seq), role: 'start' }
+ : null
+ },
start: (_context, match, reader) => {
if (match.event.type !== 'user/message') {
throw new Error('trajectory-input-message start requires user/message')
diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts
index 91cdfc682a43..2ecb66662a36 100644
--- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts
+++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts
@@ -48,16 +48,16 @@ function rootResult(
previous?: RunningToolCall,
): ToolResultNode | undefined {
if (match.event.type !== 'tool/result') return undefined
- const result = match.event.data.message.content[0]
+ const message = match.event.data.message
return {
kind: 'tool-result',
seq: match.event.seq,
time: match.event.time,
- callId: String(match.event.data.message.source.callId),
+ callId: String(message.source.callId),
call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
callTime: previous?.time ?? null,
- content: result.content,
- isError: result.isError === true,
+ content: message.content,
+ isError: message.isError === true,
...(match.event.data.error === undefined ? {} : { error: match.event.data.error }),
meta: match.event.data.meta,
subCalls: [],
diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
index c2af2403468e..66f5fbf2ffa9 100644
--- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
+++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
@@ -167,11 +167,18 @@ function systemMessage(text: string) {
id: `system-${text}`,
role: 'system',
content: [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: '@mutantcat/dsh-system-prompt' },
+ source: { kind: 'system-prompt' },
}
}
describe('Trajectory conversation Definitions', () => {
+ it('rejects developer history until presentation is implemented', () => {
+ expect(() => assembler([at(0, 'developer/message', { turn: 1, step: 1, message: {
+ id: 'developer', role: 'developer', source: { kind: 'tool-registry' },
+ content: [{ type: 'tool-addition', toolName: 'search' }],
+ } }, { surfaceOp: 'append' })])).toThrow('developer messages are not supported yet')
+ })
+
it('assembles streaming usage, preserves retry facts, and materializes interruption', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
@@ -452,14 +459,11 @@ describe('Trajectory conversation Definitions', () => {
step: 1,
message: {
id: 'result-root-a',
- role: 'user',
+ role: 'tool',
+ toolCallId: 'root-a',
source: { kind: 'tool', callId: 'root-a' },
- content: [{
- type: 'tool-result',
- toolCallId: 'root-a',
- content: [{ type: 'text', text: 'root failed' }],
- isError: true,
- }],
+ content: [{ type: 'text', text: 'root failed' }],
+ isError: true,
},
error: { name: 'ToolError', code: 'failed' },
meta: { presentation: 'raw' },
@@ -509,7 +513,7 @@ describe('Trajectory conversation Definitions', () => {
id: 'checkpoint',
role: 'user',
content: [{ type: 'text', text: 'summary checkpoint' }],
- source: { kind: 'plugin', plugin: 'compact', compactionId: 'complete' },
+ source: { kind: 'compact-checkpoint', compactionId: 'complete' },
}),
at(4, 'compaction/end', { compactionId: 'complete', turn: null }),
at(5, 'compaction/start', { compactionId: 'orphan', turn: null }),
@@ -802,7 +806,7 @@ describe('Trajectory conversation Definitions', () => {
at(11, 'step/start', { turn: 1, step: 3 }),
at(12, 'user/message', {
turn: 1, step: 3, id: 'summary', role: 'user',
- content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compaction' },
+ content: [{ type: 'text', text: 'summary' }], source: { kind: 'compact-checkpoint', compactionId: 'compaction-1' },
}, { surfaceOp: { op: 'replace', startSeq: 5, endSeq: 9 }, sourceEventSeqs: [5, 8, 9] }),
at(13, 'request/header', {
reason: 'series', header: { config: { provider: 'test', model: 'test' }, tools: [] },
@@ -855,7 +859,7 @@ describe('Trajectory conversation Definitions', () => {
at(6, 'system/message', { turn: 1, step: 1, message: systemMessage('B') }, { surfaceOp: 'append' }),
at(7, 'request/header', { reason: 'resume', header: { config: { provider: 'test', model: 'test' } } }),
at(8, 'user/message', {
- ...systemMessage('summary'), role: 'user', source: { kind: 'plugin', plugin: 'compaction' },
+ ...systemMessage('summary'), role: 'user', source: { kind: 'compact-checkpoint', compactionId: 'compaction-1' },
}, { surfaceOp: { op: 'replace', startSeq: 2, endSeq: 6 } }),
at(9, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('reply', 'reply') }),
])
diff --git a/packages/client/ui-trajectory/tests/event-projection.client.spec.ts b/packages/client/ui-trajectory/tests/event-projection.client.spec.ts
index 56bc487adabb..1243f17bda14 100644
--- a/packages/client/ui-trajectory/tests/event-projection.client.spec.ts
+++ b/packages/client/ui-trajectory/tests/event-projection.client.spec.ts
@@ -15,7 +15,7 @@ describe('Trajectory event projection', () => {
.toEqual({ role: 'inject', label: 'AGENTS.md' })
expect(contextProducer({ kind: 'agent-instructions', changes: 'bad' }).label)
.toBe('agent-instructions')
- expect(contextProducer({ kind: 'plugin', plugin: 'p' }).label).toBe('p')
+ expect(contextProducer({ kind: 'p' }).label).toBe('p')
expect(contextProducer({ kind: 'plugin', plugin: 1 }).label).toBe('plugin')
expect(contextProducer({ kind: 'skill-invocation', name: 's' }).label).toBe('s')
expect(contextProducer({ kind: 'future' }).label).toBe('future')
diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json
index 5efd5c101b1a..1d9408467a4c 100644
--- a/packages/client/ui-user-questions/package.json
+++ b/packages/client/ui-user-questions/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-client-ui-user-questions",
"description": "Web ask_user_question composer takeover and plan-review presentation UI",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json
index 327c9570075e..dff7d322c0fd 100644
--- a/packages/client/ui-workflow-run/package.json
+++ b/packages/client/ui-workflow-run/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-client-ui-workflow-run",
"description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json
index 80231d5a9847..6f1f26c388f1 100644
--- a/packages/client/ui-workspace/package.json
+++ b/packages/client/ui-workspace/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-client-ui-workspace",
"description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/web/package.json b/packages/client/web/package.json
index 1664d7269550..662aa6e9870c 100644
--- a/packages/client/web/package.json
+++ b/packages/client/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-client-web",
"description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json
index 0e514a44a2a3..aa140381f312 100644
--- a/packages/compaction/command-compact/package.json
+++ b/packages/compaction/command-compact/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-command-compact",
"description": "Human-facing slash command for explicit session compaction",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/compaction/compaction-basic/README.i18n.yaml b/packages/compaction/compaction-basic/README.i18n.yaml
index 5a77ab3b7849..e72e39397a30 100644
--- a/packages/compaction/compaction-basic/README.i18n.yaml
+++ b/packages/compaction/compaction-basic/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compaction/compaction-basic/README.md
-README.md: c9b70cbe3021fed90cdec065171f1f78d27065df
-README.zh.md: bdc1f9d2f34205da5de1f73dfeee2bcb549f5117
+README.md: cacfdc3ae5315d0616b54367c92171044d96910c
+README.zh.md: 7d33f45df671410ebdbe5aa08bb53fcbc0654109
diff --git a/packages/compaction/compaction-basic/README.md b/packages/compaction/compaction-basic/README.md
index c9b70cbe3021..cacfdc3ae531 100644
--- a/packages/compaction/compaction-basic/README.md
+++ b/packages/compaction/compaction-basic/README.md
@@ -115,7 +115,7 @@ Pressure policy resolves capacity from the adapter that owns the durable route.
### Summarization mechanics
-A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the `AgentOptions` pair, without running the loop-only `agent/request` extension point. The call replays the derived `system/message` at surface node 0 as the leading entry of `messages`, followed by the shadowed-region messages (including a shadowed in-history `system/message` in its surface position), and carries the header's tools verbatim — including image references, which the selected adapter must resolve or explicitly reject — and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. An empty-content system head contributes no message but remains outside the compacted range. The call sets `GenerateOptions.purpose` to `compaction`; only returned text enters the checkpoint, excluding reasoning and tool calls. Image output fails with `UNSUPPORTED_CONTENT` rather than disappearing. The replacement user message frames the summary with `` tags; the raw summary remains on the `compaction/summary` event.
+A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the `AgentOptions` pair, without running the loop-only `agent/request` extension point. The call replays the derived `system/message` at surface node 0 as the leading entry of `messages`, followed by the shadowed-region messages (including a shadowed in-history `system/message` in its surface position), and carries the header's tools verbatim — including image references, which the selected adapter must resolve or explicitly reject — and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. An empty-content system head contributes no message but remains outside the compacted range. The final instruction is a frozen `RequestUserInput` without durable identity or source; the replayed history and persisted checkpoint remain durable messages. The call sets `GenerateOptions.purpose` to `compaction`; only returned text enters the checkpoint, excluding reasoning and tool calls. Image output fails with `UNSUPPORTED_CONTENT` rather than disappearing. The replacement user message frames the summary with `` tags; the raw summary remains on the `compaction/summary` event.
### The region transaction
diff --git a/packages/compaction/compaction-basic/README.zh.md b/packages/compaction/compaction-basic/README.zh.md
index bdc1f9d2f342..7d33f45df671 100644
--- a/packages/compaction/compaction-basic/README.zh.md
+++ b/packages/compaction/compaction-basic/README.zh.md
@@ -115,7 +115,7 @@ kind: "package-reference"
### 摘要机制
-直接 `ctx.llm.stream()` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 `AgentOptions` 对,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用将 surface 节点 0 处派生的 `system/message` 作为 `messages` 的首项回放,后接已遮蔽区域消息(包括位于其 surface 位置的被遮蔽历史内 `system/message`),并逐字携带 header 的工具——包括所选适配器必须解析或明确拒绝的图片引用——并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。空内容系统头节点不贡献消息,但仍处于压缩范围之外。调用将 `GenerateOptions.purpose` 设为 `compaction`;只有返回文本进入检查点,推理与工具调用都会被排除。图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。替换 user 消息用 `` 标签框定摘要;原始摘要保留在 `compaction/summary` 事件上。
+直接 `ctx.llm.stream()` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 `AgentOptions` 对,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用将 surface 节点 0 处派生的 `system/message` 作为 `messages` 的首项回放,后接已遮蔽区域消息(包括位于其 surface 位置的被遮蔽历史内 `system/message`),并逐字携带 header 的工具——包括所选适配器必须解析或明确拒绝的图片引用——并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。空内容系统头节点不贡献消息,但仍处于压缩范围之外。最终指令是冻结的 `RequestUserInput`,不含持久身份或来源;回放历史和持久化检查点仍然使用持久消息。调用将 `GenerateOptions.purpose` 设为 `compaction`;只有返回文本进入检查点,推理与工具调用都会被排除。图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。替换 user 消息用 `` 标签框定摘要;原始摘要保留在 `compaction/summary` 事件上。
### 区域事务
diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json
index ce4c3d4ddc0a..daf5986d8c0f 100644
--- a/packages/compaction/compaction-basic/package.json
+++ b/packages/compaction/compaction-basic/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-compaction-basic",
"description": "Token-meter-driven compaction policy and LLM summarization backend for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts
index f43d3f83edcf..db2c46afc541 100644
--- a/packages/compaction/compaction-basic/src/region.ts
+++ b/packages/compaction/compaction-basic/src/region.ts
@@ -23,7 +23,6 @@ import { SessionSeq, type Session, type SessionEvent } from '@mutantcat/dsh-sess
import type { Agent } from '@mutantcat/dsh-agent'
import { frameSummary } from './summarizer.ts'
import type { SummarizationInput, SummaryResult } from './summarizer.ts'
-
interface RegionDependencies {
readonly meter: TokenMeter
summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise
diff --git a/packages/compaction/compaction-basic/src/summarizer.ts b/packages/compaction/compaction-basic/src/summarizer.ts
index 4c56ae97545d..b72712dd2bf3 100644
--- a/packages/compaction/compaction-basic/src/summarizer.ts
+++ b/packages/compaction/compaction-basic/src/summarizer.ts
@@ -5,9 +5,10 @@
*/
import type { Context } from '@mutantcat/cordis'
-import { contentHasImage, createUserMessage, BlockAssembler, LlmError } from '@mutantcat/dsh-llm'
+import { contentHasImage, BlockAssembler, LlmError } from '@mutantcat/dsh-llm'
+import { deepFreeze } from '@mutantcat/dsh-util-values'
import type {
- ContentBlock, FinishReason, GenerateOptions, Message, TokenUsage, ToolSchema,
+ ContentBlock, FinishReason, GenerateOptions, Message, RequestMessage, TokenUsage, ToolSchema,
} from '@mutantcat/dsh-llm'
import type { Agent } from '@mutantcat/dsh-agent'
@@ -141,11 +142,11 @@ export async function summarizeWithLlm(
}
const assembler = new BlockAssembler()
- const messages: Message[] = [
+ const messages: RequestMessage[] = [
...input.messages,
- createUserMessage({
+ deepFreeze({
+ role: 'user',
content: [{ type: 'text', text: COMPACTION_INSTRUCTION }],
- source: { kind: 'plugin', plugin: 'dsh-compaction-basic' },
}),
]
const options: GenerateOptions = {
diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
index 8774679b85b8..46c48ba623c4 100644
--- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
+++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
@@ -14,6 +14,7 @@ import {
} from '@mutantcat/dsh-compaction-basic/src/config.ts'
import type { CompactionResult } from '@mutantcat/dsh-compaction'
import LlmRuntime, { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, createSystemMessage, createToolResultMessage, LlmAdapter , createMessage } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import type {
ContentBlock,
GenerateOptions,
@@ -29,6 +30,12 @@ import TokenMeter from '@mutantcat/dsh-token-meter'
import { agentEvents, type Agent, type RequestErrorAction } from '@mutantcat/dsh-agent'
import ToolResultPruner from '@mutantcat/dsh-compaction-tool-result-pruner'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
const SIGNAL = new AbortController().signal
const MODEL = 'test-model'
@@ -89,26 +96,21 @@ function agent(session: Session, model?: string): Agent {
} as Agent
}
-/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */
+/** Flatten every text fragment the summarizer received across all messages. */
function summarizedText(input: SummarizationInput): string {
- const collect = (blocks: readonly ContentBlock[]): string =>
- blocks.map(block =>
- block.type === 'text' ? block.text
- : block.type === 'tool-result' ? collect(block.content)
- : '').join('\n')
- return input.messages.map(message => collect(message.content)).join('\n')
+ return input.messages.flatMap(message => message.content)
+ .map(block => block.type === 'text' ? block.text : '')
+ .join('\n')
}
/** A minimal replayed prefix carrying one user message of the given text. */
function promptInput(text: string): SummarizationInput {
return { messages: [createUserMessage({
content: [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})] }
}
-const SYSTEM_PROMPT_PLUGIN = '@mutantcat/dsh-system-prompt'
-
/**
* Closed two-message turns followed by one open turn for durable compaction events.
* A `system` text puts a `system/message` at surface node 0 before turn 1's user message.
@@ -121,7 +123,7 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim(), system?: s
session.append('system/message', {
turn,
step: 1,
- message: createSystemMessage(system, SYSTEM_PROMPT_PLUGIN),
+ message: createSystemMessage(system),
}, { surfaceOp: 'append' })
}
session.append('user/message', createUserMessage({
@@ -774,8 +776,8 @@ describe('pressure measurement and retention', () => {
for (const message of messages) {
for (const block of message.content) {
if (block.type === 'tool-call') calls.add(block.id)
- if (block.type === 'tool-result') expect(calls.has(block.toolCallId)).toBe(true)
}
+ if (message.role === 'tool') expect(calls.has(message.toolCallId)).toBe(true)
}
})
@@ -890,7 +892,7 @@ describe('optional model-free tool-result pruning', () => {
expect(await compactIfNeeded(compact, session)).not.toBeNull()
expect(compact.calls).toHaveLength(1)
const original = session.snapshotEvents().find(event => event.type === 'tool/result')
- expect(original?.type === 'tool/result' && original.data.message.content[0].content[0])
+ expect(original?.type === 'tool/result' && original.data.message.content[0])
.toEqual({ type: 'text', text: 'X'.repeat(3_000) })
expect(session.snapshotEvents().filter(event =>
event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
@@ -1135,7 +1137,7 @@ describe('compaction region transaction', () => {
compact.mutateDuringSummary = () => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'concurrent surface mutation' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}), { surfaceOp: 'append' })
}
const nodes = session.surface.nodes
@@ -1275,7 +1277,12 @@ describe('default one-shot summarizer', () => {
expect(adapter.lastOptions).not.toHaveProperty('system')
expect(adapter.lastOptions?.tools).toEqual(tools)
expect(adapter.lastOptions?.messages.slice(0, -1)).toEqual(prefix)
- expect(adapter.lastOptions?.messages.at(-1)).toMatchObject({ role: 'user' })
+ const instruction = adapter.lastOptions?.messages.at(-1)
+ expect(instruction).toMatchObject({ role: 'user' })
+ expect(instruction).not.toHaveProperty('id')
+ expect(instruction).not.toHaveProperty('source')
+ expect(Object.isFrozen(instruction)).toBe(true)
+ expect(Object.isFrozen(instruction?.content[0])).toBe(true)
expect(result.shadowedSeqs).toEqual(nodes.slice(start, start + 2))
if (system !== undefined) expect(session.surface.nodes[0]).toBe(nodes[0])
})
@@ -1346,9 +1353,9 @@ describe('default one-shot summarizer', () => {
},
},
],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})
- const system = createSystemMessage('REPLAYED SYSTEM', SYSTEM_PROMPT_PLUGIN)
+ const system = createSystemMessage('REPLAYED SYSTEM')
await compact.runSummarize({
tools,
messages: [system, prefix],
@@ -1386,10 +1393,10 @@ describe('default one-shot summarizer', () => {
ctx.llm.registerAdapter(['policy-summary'], policyAdapter)
const prefix: Message = createUserMessage({
content: [{ type: 'text', text: 'warm prefix' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})
- const system = createSystemMessage('WARM SYSTEM', SYSTEM_PROMPT_PLUGIN)
+ const system = createSystemMessage('WARM SYSTEM')
const output = await compact.runSummarize({
messages: [system, prefix],
}, agent(conversation(1), 'fallback'))
@@ -1525,20 +1532,16 @@ describe('default one-shot summarizer', () => {
.rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
})
- it('rejects image summary output nested in a tool result', async () => {
+ it('rejects image summary output', async () => {
const { compact } = await summarizerHarness([{
- type: 'tool-result',
- toolCallId: ToolCallId('summary-tool'),
- content: [{
- type: 'image',
- attachment: {
- attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
- mediaType: 'image/png',
- bytes: 1,
- width: 1,
- height: 1,
- },
- }],
+ type: 'image',
+ attachment: {
+ attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
+ mediaType: 'image/png',
+ bytes: 1,
+ width: 1,
+ height: 1,
+ },
}])
await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
.rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
@@ -2098,7 +2101,7 @@ describe('route-priced image pressure', () => {
}]
const framed = ctx.tokenMeter.estimateMessage(createUserMessage({
content: frameSummary(compact.summary),
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}))
expect(framed).toBeGreaterThan(imageNode.heuristicTokens)
expect(framed).toBeLessThan(imageNode.tokens)
diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
index 9af0badae3ce..6b86527cc2fa 100644
--- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
+++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
@@ -336,7 +336,7 @@ describe('token pressure after loop-admitted system prompts', () => {
agent.session.append('system/message', {
turn,
step,
- message: createSystemMessage('retry guidance', '@mutantcat/dsh-system-prompt'),
+ message: createSystemMessage('retry guidance'),
}, { surfaceOp: { op: 'replace', startSeq: node, endSeq: node }, sourceEventSeqs: [node] })
return { kind: 'retry' }
})
diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
index 35a0ee2173a0..42c15773fe9f 100644
--- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
+++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
@@ -21,12 +21,14 @@ import type {
ContentBlock,
LlmResolvedModelInfo,
Message,
+ RequestMessage,
StreamChunk,
TokenUsage,
} from '@mutantcat/dsh-llm'
-import SessionStore, { Session, SessionId, type SessionEvent } from '@mutantcat/dsh-session'
+import SessionStore, { buildForkSeed, Session, SessionId, type SessionEvent } from '@mutantcat/dsh-session'
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import LlmRuntime from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import TokenMeter from '@mutantcat/dsh-token-meter'
import type { Agent } from '@mutantcat/dsh-agent'
import type {
@@ -34,6 +36,14 @@ import type {
SummaryResult,
} from '@mutantcat/dsh-compaction-basic/src/summarizer.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'listener': { kind: 'listener' } & ContextFormed
+ 'rival': { kind: 'rival' } & ContextFormed
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
const MODEL = 'mock'
const SIGNAL = new AbortController().signal
const PROMPT = 'older conversation history '.repeat(60)
@@ -69,7 +79,7 @@ class GatedCompactionEngine extends BasicCompactionEngine {
/** One text answer per request, with a context window large enough to avoid pressure. */
class TextAdapter extends LlmAdapter {
- readonly requests: Message[][] = []
+ readonly requests: RequestMessage[][] = []
override resolveModel(provider: string, model: string): Promise {
return Promise.resolve({
@@ -80,7 +90,7 @@ class TextAdapter extends LlmAdapter {
})
}
- override async * stream(options: { messages: readonly Message[] }): AsyncIterable {
+ override async * stream(options: { messages: readonly RequestMessage[] }): AsyncIterable {
this.requests.push([...options.messages])
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'answer' } }
@@ -312,7 +322,7 @@ describe('compactNow through the real loop', () => {
compact.duringSummary = () => {
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'INJECTED CONTEXT' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}))
}
@@ -321,7 +331,7 @@ describe('compactNow through the real loop', () => {
expect(result).not.toBeNull()
const start = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/start')
const injected = agent.inbox.nextStep.find(message =>
- message.source.kind === 'plugin' && message.source.plugin === 'test')
+ message.source.kind === 'test')
const end = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/end')
expect(start).toBeDefined()
expect(injected).toBeDefined()
@@ -350,7 +360,7 @@ describe('compactNow through the real loop', () => {
attempts.push(event.type)
agent.inject(createUserMessage({
content: [{ type: 'text', text: `from ${event.type}` }],
- source: { kind: 'plugin', plugin: 'listener' },
+ source: { kind: 'listener' },
}))
})
@@ -361,7 +371,7 @@ describe('compactNow through the real loop', () => {
expect(agent.session.deriveMessages()[0]?.role).toBe('system')
expect(derivedText(agent.session)[1]).toContain('checkpoint')
expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message'
- && event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0)
+ && event.data.source.kind === 'listener')).toHaveLength(0)
const types = compactEvents(agent.session).map(event => event.type)
expect(types).toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
})
@@ -477,6 +487,28 @@ describe('compactNow transaction and failure classification', () => {
expect(compact.calls).toHaveLength(1)
})
+ it('ignores an unmatched bracket a mid-turn fork seed cut open', async () => {
+ // A fork boundary inside the source's live compaction bracket: the seed
+ // keeps the unmatched compaction/start and balances only the turn with
+ // forked closers — never a synthetic compaction/end. The child's end-seed
+ // marker proves the inherited lock stale, so compaction is not blocked.
+ const { compact } = detachedService()
+ const original = closedConversation(2)
+ original.append('turn/start', { turn: 3 })
+ original.append('compaction/start', {
+ compactionId: CompactionId('forked-manual-compaction'),
+ turn: 3,
+ })
+ const seed = buildForkSeed(original.snapshotEvents(), original.snapshotEvents().at(-1)!.seq)
+ expect(seed.filter(event => event.type === 'compaction/end')).toEqual([])
+ expect(seed.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'forked' } } })
+ const child = Session.create(SessionId('forked-orphan'), seed)
+ const agent = fakeAgent(child, () => () => undefined)
+
+ await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
+ expect(compact.calls).toHaveLength(1)
+ })
+
it('scans a stale orphan independently of later repaired turn state', async () => {
const { compact } = detachedService()
const original = closedConversation(2)
@@ -521,7 +553,7 @@ describe('compactNow transaction and failure classification', () => {
const [head] = session.surface.nodes
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'competing replacement' }],
- source: { kind: 'plugin', plugin: 'rival' },
+ source: { kind: 'rival' },
}), {
surfaceOp: { op: 'replace', startSeq: head!, endSeq: head! },
sourceEventSeqs: [head!],
@@ -542,7 +574,7 @@ describe('compactNow transaction and failure classification', () => {
const middle = session.surface.nodes[1]
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'rewritten middle node' }],
- source: { kind: 'plugin', plugin: 'rival' },
+ source: { kind: 'rival' },
}), {
surfaceOp: { op: 'replace', startSeq: middle!, endSeq: middle! },
sourceEventSeqs: [middle!],
@@ -573,7 +605,7 @@ describe('compactNow transaction and failure classification', () => {
queueMicrotask(() => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late competing replacement' }],
- source: { kind: 'plugin', plugin: 'rival' },
+ source: { kind: 'rival' },
}), {
surfaceOp: { op: 'replace', startSeq: head, endSeq: head },
sourceEventSeqs: [head],
diff --git a/packages/compaction/compaction-image-offload/package.json b/packages/compaction/compaction-image-offload/package.json
index b4de4b4a5a72..a76bed98a5bb 100644
--- a/packages/compaction/compaction-image-offload/package.json
+++ b/packages/compaction/compaction-image-offload/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-compaction-image-offload",
"description": "Durable image offload for image-capable routes: replace over-budget request images with placeholders and retry",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/compaction/compaction-image-offload/src/image-offload.ts b/packages/compaction/compaction-image-offload/src/image-offload.ts
index a3f030c03485..7d978add9f20 100644
--- a/packages/compaction/compaction-image-offload/src/image-offload.ts
+++ b/packages/compaction/compaction-image-offload/src/image-offload.ts
@@ -34,8 +34,6 @@ export function offloadOldestImages(session: Session, sourceEventSeqs: readonly
count -= 1
}
imageIndex += 1
- } else if (block.type === 'tool-result') {
- visit(block.content)
}
}
}
diff --git a/packages/compaction/compaction-image-offload/src/project-message.ts b/packages/compaction/compaction-image-offload/src/project-message.ts
index 22df15e7ef78..d13d2c333ed1 100644
--- a/packages/compaction/compaction-image-offload/src/project-message.ts
+++ b/packages/compaction/compaction-image-offload/src/project-message.ts
@@ -24,9 +24,6 @@ export function offloadMessageImages(message: Message, indexes: readonly number[
selected += 1
}
imageIndex += 1
- } else if (block.type === 'tool-result') {
- const content = visit(block.content)
- if (content !== block.content) projected = { ...block, content }
}
if (projected !== block) next ??= blocks.slice(0, index)
next?.push(projected)
diff --git a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts
index 54577df22e9a..fe37f3a6fdd7 100644
--- a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts
+++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts
@@ -87,13 +87,11 @@ function image(name: string): Extract {
function offloadedNames(options: GenerateOptions): string[] {
const names: string[] = []
- const visit = (blocks: readonly ContentBlock[]): void => {
- for (const block of blocks) {
+ for (const message of options.messages) {
+ for (const block of message.content) {
if (block.type === 'image' && block.offloaded === true) names.push(block.attachment.name ?? '')
- if (block.type === 'tool-result') visit(block.content)
}
}
- for (const message of options.messages) visit(message.content)
return names
}
@@ -235,7 +233,7 @@ describe('compaction-image-offload', () => {
})
agent.followup(createUserMessage({
- content: [image('a'), { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image('b')] }, image('c')],
+ content: [image('a'), image('b'), image('c')],
source: { kind: 'user' },
}))
await agent.whenIdle()
@@ -297,32 +295,54 @@ describe('compaction-image-offload', () => {
]])
})
- it('offloads a nested tool-result occurrence and leaves later images untouched', async () => {
+ it('offloads a tool-result image and leaves later images untouched', async () => {
const adapter = new ScriptedAdapter([offloadRequired(1), textResponse('sent')])
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('offload-tool-result'), { provider: 'mock', model: 'mock' })
+ const emptyCallId = ToolCallId('empty')
const callId = ToolCallId('shot')
+ const innerCallId = ToolCallId('inner')
agent.session.append('turn/start', { turn: 0 })
agent.session.append('assistant/message', {
turn: 0,
step: 1,
message: createAssistantMessage({
- content: [{ type: 'tool-call', id: callId, name: 'read_image', arguments: '{}' }],
+ content: [
+ { type: 'tool-call', id: emptyCallId, name: 'read_image', arguments: '{}' },
+ { type: 'tool-call', id: callId, name: 'read_image', arguments: '{}' },
+ { type: 'tool-call', id: innerCallId, name: 'read_image', arguments: '{}' },
+ ],
source: { provider: 'mock', model: 'mock' },
}),
stream: [],
}, { surfaceOp: 'append' })
+ agent.session.append('tool/call', { turn: 0, step: 1, callId: emptyCallId, name: 'read_image', arguments: '{}' })
+ agent.session.append('tool/result', {
+ turn: 0,
+ step: 1,
+ message: createToolResultMessage({
+ callId: emptyCallId,
+ content: [{ type: 'text', text: 'no image' }],
+ isError: false,
+ }),
+ }, { surfaceOp: 'append' })
agent.session.append('tool/call', { turn: 0, step: 1, callId, name: 'read_image', arguments: '{}' })
const result = agent.session.append('tool/result', {
turn: 0,
step: 1,
message: createToolResultMessage({
callId,
- content: [
- { type: 'tool-result', toolCallId: ToolCallId('empty'), content: [{ type: 'text', text: 'no image' }] },
- image('first'),
- { type: 'tool-result', toolCallId: ToolCallId('inner'), content: [image('second')] },
- ],
+ content: [image('first')],
+ isError: false,
+ }),
+ }, { surfaceOp: 'append' })
+ agent.session.append('tool/call', { turn: 0, step: 1, callId: innerCallId, name: 'read_image', arguments: '{}' })
+ agent.session.append('tool/result', {
+ turn: 0,
+ step: 1,
+ message: createToolResultMessage({
+ callId: innerCallId,
+ content: [image('second')],
isError: false,
}),
}, { surfaceOp: 'append' })
diff --git a/packages/compaction/compaction-image-offload/tests/projection.spec.ts b/packages/compaction/compaction-image-offload/tests/projection.spec.ts
index 51dd6aa82522..a25966b5cf70 100644
--- a/packages/compaction/compaction-image-offload/tests/projection.spec.ts
+++ b/packages/compaction/compaction-image-offload/tests/projection.spec.ts
@@ -21,13 +21,11 @@ function input(session: Session, content: ContentBlock[] = [image, image]) {
function marked(session: Session): boolean[] {
const result: boolean[] = []
- const visit = (content: readonly ContentBlock[]): void => {
- for (const block of content) {
+ for (const message of session.deriveMessages()) {
+ for (const block of message.content) {
if (block.type === 'image') result.push(block.offloaded === true)
- if (block.type === 'tool-result') visit(block.content)
}
}
- for (const message of session.deriveMessages()) visit(message.content)
return result
}
@@ -36,8 +34,8 @@ describe('durable image selections', () => {
const session = createSession(SessionId('images'))
const source = input(session, [
{ type: 'text', text: 'before' }, image,
- { type: 'tool-result', toolCallId: ToolCallId('nested'), content: [image, { type: 'text', text: 'after' }] },
- { type: 'tool-result', toolCallId: ToolCallId('text'), content: [{ type: 'text', text: 'unchanged' }] },
+ image, { type: 'text', text: 'after' },
+ { type: 'text', text: 'unchanged' },
image,
])
const before = session.deriveMessages()
diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json
index ee8ae70fbbd4..9658106568b3 100644
--- a/packages/compaction/compaction-tool-result-pruner/package.json
+++ b/packages/compaction/compaction-tool-result-pruner/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-compaction-tool-result-pruner",
"description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/compaction/compaction-tool-result-pruner/src/index.ts b/packages/compaction/compaction-tool-result-pruner/src/index.ts
index e376aecdfac5..96872de52553 100644
--- a/packages/compaction/compaction-tool-result-pruner/src/index.ts
+++ b/packages/compaction/compaction-tool-result-pruner/src/index.ts
@@ -146,17 +146,13 @@ export class ToolResultPruner extends Service {
let charsRemoved = 0
for (const { seq, event } of candidates) {
const original = session.deriveEventMessage(event) as ToolResultMessage
- const result = original.content[0]
- const content = this.pruneContent(result.content)
+ const content = this.pruneContent(original.content)
if (content === null) continue
- const charsBefore = this.measureContent(result.content)
+ const charsBefore = this.measureContent(original.content)
const charsAfter = this.measureContent(content)
const message = freezeMessage({
...original,
- content: [{
- ...result,
- content,
- }] as [typeof result],
+ content,
})
// Shadow-price protocol: the metering event and its replacement are
// appended synchronously adjacent, so pure consumers subtract the
diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts
index 489ecd888235..d96f6a430978 100644
--- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts
+++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts
@@ -212,10 +212,7 @@ describe('ToolResultPruner session transaction', () => {
type: 'tool/result',
data: {
message: {
- content: [{
- type: 'tool-result',
- content: [{ type: 'text', text: 'x'.repeat(100) }],
- }],
+ content: [{ type: 'text', text: 'x'.repeat(100) }],
},
},
})
@@ -227,7 +224,9 @@ describe('ToolResultPruner session transaction', () => {
isError: true,
message: {
source: { kind: 'tool', callId: ToolCallId('one') },
- content: [{ type: 'tool-result', isError: true }],
+ role: 'tool',
+ toolCallId: ToolCallId('one'),
+ isError: true,
},
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { diff: ['a', 'b'] },
diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json
index e162352be85b..77ab78b2f422 100644
--- a/packages/compaction/compaction/package.json
+++ b/packages/compaction/compaction/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-compaction",
"description": "Abstract compaction service seam (ctx.compaction) for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/compaction/compaction/src/checkpoint.ts b/packages/compaction/compaction/src/checkpoint.ts
index 843c14c6bb01..963120b8e258 100644
--- a/packages/compaction/compaction/src/checkpoint.ts
+++ b/packages/compaction/compaction/src/checkpoint.ts
@@ -16,7 +16,7 @@ import type { MessageSource } from '@mutantcat/dsh-llm/message'
import type { CommandId } from '@mutantcat/dsh-commands/brand'
import type { CompactionId } from './brand.ts'
-const COMPACT_CHECKPOINT_MARKER = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
+const COMPACT_CHECKPOINT_MARKER = Object.freeze({ kind: 'compact-checkpoint' } as const)
/** Message source carried by a concrete compaction checkpoint. */
export type CompactionCheckpointSource = typeof COMPACT_CHECKPOINT_MARKER & {
@@ -46,6 +46,6 @@ export function compactCheckpointSource(
* @param source - source restored from a surface user message.
* @returns whether the source carries the backend-independent checkpoint marker.
*/
-export function isCompactCheckpointSource(source: MessageSource): boolean {
- return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_MARKER.plugin
+export function isCompactCheckpointSource(source: MessageSource): source is CompactionCheckpointSource {
+ return source.kind === 'compact-checkpoint'
}
diff --git a/packages/compaction/compaction/src/index.ts b/packages/compaction/compaction/src/index.ts
index 30788ae193f5..8d30e70858b0 100644
--- a/packages/compaction/compaction/src/index.ts
+++ b/packages/compaction/compaction/src/index.ts
@@ -11,6 +11,7 @@ import { Context, Service } from '@mutantcat/cordis'
import type { Session, SessionSeq } from '@mutantcat/dsh-session'
import type { CommandId } from '@mutantcat/dsh-commands/brand'
import type { CompactionResult } from './types.ts'
+import type { CompactionCheckpointSource } from './checkpoint.ts'
export type { CompactionResult } from './types.ts'
export { CompactionId } from './brand.ts'
@@ -21,6 +22,12 @@ export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pair
export { compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts'
export type { CompactionCheckpointSource } from './checkpoint.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'compact-checkpoint': CompactionCheckpointSource
+ }
+}
+
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'
diff --git a/packages/compaction/compaction/src/invariant.ts b/packages/compaction/compaction/src/invariant.ts
index 114fce00fcef..e2d75b551a2f 100644
--- a/packages/compaction/compaction/src/invariant.ts
+++ b/packages/compaction/compaction/src/invariant.ts
@@ -97,10 +97,9 @@ function validateSourceCommandId(
/** Validate one replacement checkpoint against its open compaction transaction. */
function validateCheckpoint(
trace: SessionTrace,
- event: SessionEvent<'user/message'>,
+ source: CompactionCheckpointSource,
fail: InvariantFailure,
): void {
- const source = event.data.source as typeof event.data.source & Partial
validateId(source.compactionId, 'compaction checkpoint compactionId', fail)
if (source.sourceCommandId !== undefined) {
validateId(source.sourceCommandId, 'compaction checkpoint sourceCommandId', fail)
@@ -190,7 +189,7 @@ function validateCompactionEvent(
if (event.type === 'user/message'
&& isReplacementSurfaceEvent(event)
&& isCompactCheckpointSource(event.data.source)) {
- validateCheckpoint(trace, event, fail)
+ validateCheckpoint(trace, event.data.source, fail)
return undefined
}
if (event.type !== 'compaction/start' && event.type !== 'compaction/summary' && event.type !== 'compaction/end') {
diff --git a/packages/compaction/compaction/tests/compaction.spec.ts b/packages/compaction/compaction/tests/compaction.spec.ts
index 9cc0ff2f8d49..5bbc987dd588 100644
--- a/packages/compaction/compaction/tests/compaction.spec.ts
+++ b/packages/compaction/compaction/tests/compaction.spec.ts
@@ -1,4 +1,5 @@
import { createUserMessage } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from '@mutantcat/cordis'
import {
@@ -13,6 +14,12 @@ import type { SessionSeq } from '@mutantcat/dsh-session'
import type { CompactionAgentContext } from '@mutantcat/dsh-compaction'
import type { ManualCompactAgentContext } from '@mutantcat/dsh-compaction'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'other': { kind: 'other' } & ContextFormed
+ }
+}
+
/**
* A trivial concrete CompactionEngine implementing the abstract contract. The
* Service Definition package owns no algorithm — these tests exercise its contract:
@@ -146,7 +153,7 @@ describe('CompactionEngine seam', () => {
&& isCompactCheckpointSource(event.data.source))
expect(checkpoint?.type === 'user/message' && checkpoint.data.source)
.toEqual(compactCheckpointSource(result.compactionId))
- expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false)
+ expect(isCompactCheckpointSource({ kind: 'other' })).toBe(false)
expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false)
expect(session.snapshotEvents().filter(e => e.type.startsWith('compaction/')).map(e => e.type))
.toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
diff --git a/packages/compaction/compaction/tests/tool-pairing.spec.ts b/packages/compaction/compaction/tests/tool-pairing.spec.ts
index 834102ff97b4..68718500058b 100644
--- a/packages/compaction/compaction/tests/tool-pairing.spec.ts
+++ b/packages/compaction/compaction/tests/tool-pairing.spec.ts
@@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest'
import { createUserMessage, ToolCallId , createMessage, createToolResultMessage } from '@mutantcat/dsh-llm'
-import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@mutantcat/dsh-compaction'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
+import { CompactionId, compactCheckpointSource, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@mutantcat/dsh-compaction'
import { Session, SessionId, SessionSeq } from '@mutantcat/dsh-session'
import type { SessionEvent, SessionSeq as SessionSeqType } from '@mutantcat/dsh-session'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
const SURFACE = { surfaceOp: 'append' as const }
function seqOf(session: Session, type: SessionEvent['type'], nth = 0): SessionSeqType {
@@ -138,7 +145,7 @@ describe('tool-pairing boundaries', () => {
}, SURFACE)
midStep.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'background update' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}), SURFACE)
midStep.append('tool/result', {
turn: 1, step: 1,
@@ -170,7 +177,7 @@ describe('tool-pairing surface identity', () => {
const nodes = session.surface.nodes
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }],
- source: { kind: 'plugin', plugin: 'compact' },
+ source: compactCheckpointSource(CompactionId('tool-pairing-compaction')),
}), {
surfaceOp: { op: 'replace', startSeq: nodes[0]!, endSeq: nodes.at(-1)! },
sourceEventSeqs: [...nodes],
diff --git a/packages/computer-use/computer-use/package.json b/packages/computer-use/computer-use/package.json
index 957870e678aa..7587840f70fb 100644
--- a/packages/computer-use/computer-use/package.json
+++ b/packages/computer-use/computer-use/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-computer-use",
"description": "Exclusive named computer-use provider registration",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json
index e5e97323c70a..d356e71981c7 100644
--- a/packages/context/agent-instructions/package.json
+++ b/packages/context/agent-instructions/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-agent-instructions",
"description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts
index 6741cbcea36f..44d660d86172 100644
--- a/packages/context/agent-instructions/src/state.ts
+++ b/packages/context/agent-instructions/src/state.ts
@@ -93,7 +93,7 @@ function agentInstructionsHook(text: string, changes: AgentInstructionChange[]):
export function agentInstructionsMessage(text: string): Message {
return createUserMessage({
content: [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: name },
+ source: { kind: name, form: 'instructions', changes: [] },
})
}
diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts
index 23dfc8e10f4f..f61140a1d3d5 100644
--- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts
+++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts
@@ -5,11 +5,12 @@ import { afterAll, describe, expect, it, vi } from 'vitest'
import { Context } from '@mutantcat/cordis'
import Loader from '@mutantcat/cordis-plugin-loader'
import * as AgentInstructions from '@mutantcat/dsh-agent-instructions'
-import LlmRuntime, { createUserMessage, ToolCallId, type Message, type StreamChunk } from '@mutantcat/dsh-llm'
+import LlmRuntime, { createUserMessage, ToolCallId, type Message, type MessageSource, type StreamChunk } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { SessionId, SessionSeq, type SessionEvent, type SurfaceIntent, type UserMessage } from '@mutantcat/dsh-session'
+import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import AgentRegistry, { agentEvents, type Agent } from '@mutantcat/dsh-agent'
import AgentLoop, { turnBoundaryProjectionDefinition } from '@mutantcat/dsh-agent-loop'
-import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import { FileSystem, FsTargetKey, FsVersion } from '@mutantcat/dsh-fs'
import type {
FsDirEntry,
@@ -48,6 +49,21 @@ import {
mountAgentLoopTestHarness,
} from '@mutantcat/dsh-agent-loop-testkit'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'downstream': { kind: 'downstream' } & ContextFormed
+ 'other': { kind: 'other' } & ContextFormed
+ 'test-skills': { kind: 'test-skills' } & ContextFormed
+ }
+}
+
+type CheckpointSource = Extract
+
+/** Build a typed checkpoint source for an instruction projection fixture. */
+function checkpointSource(compactionId: string): CheckpointSource {
+ return { kind: 'compact-checkpoint', compactionId: compactionId as CheckpointSource['compactionId'] }
+}
+
/** Per-candidate reconciliation scope key: directory paired with the file name. */
const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName)
@@ -251,7 +267,7 @@ function stubToolExecution(
}
}
-function blocksText(blocks: { type: string; text?: string }[] | undefined): string {
+function blocksText(blocks: readonly { type: string; text?: string }[] | undefined): string {
return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? ''
}
@@ -1746,7 +1762,7 @@ describe('workspace context request injection', () => {
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
- source: { kind: 'plugin', plugin: 'compact' },
+ source: checkpointSource('instruction-compaction-1'),
}), {
surfaceOp: { op: 'replace', startSeq: baseline!.seq, endSeq: baseline!.seq },
sourceEventSeqs: [baseline!.seq],
@@ -1779,7 +1795,7 @@ describe('workspace context request injection', () => {
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
- source: { kind: 'plugin', plugin: 'compact' },
+ source: checkpointSource('instruction-compaction-2'),
}), {
surfaceOp: { op: 'replace', startSeq: baseline!.seq, endSeq: baseline!.seq },
sourceEventSeqs: [baseline!.seq],
@@ -1882,7 +1898,7 @@ describe('workspace context request injection', () => {
...decision,
messages: [
...decision.messages,
- createUserMessage({ content: [{ type: 'text', text: 'Available skills' }], source: { kind: 'plugin', plugin: 'test-skills' } }),
+ createUserMessage({ content: [{ type: 'text', text: 'Available skills' }], source: { kind: 'test-skills' } }),
],
}
})
@@ -3710,7 +3726,7 @@ describe('dynamic nested workspace context injection', () => {
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
- source: { kind: 'plugin', plugin: 'compact' },
+ source: checkpointSource('instruction-compaction-3'),
}), {
surfaceOp: { op: 'replace', startSeq: SessionSeq(contextSeq), endSeq: SessionSeq(contextSeq) },
sourceEventSeqs: [SessionSeq(contextSeq)],
@@ -3757,7 +3773,7 @@ describe('dynamic nested workspace context injection', () => {
})
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
- source: { kind: 'plugin', plugin: 'compact' },
+ source: checkpointSource('instruction-compaction-4'),
}), {
surfaceOp: { op: 'replace', startSeq: baseline!.seq, endSeq: baseline!.seq },
sourceEventSeqs: [baseline!.seq],
@@ -3901,7 +3917,7 @@ describe('dynamic nested workspace context injection', () => {
}), { surfaceOp: 'append' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'foreign plugin context' }],
- source: { kind: 'plugin', plugin: 'other' },
+ source: { kind: 'other' },
}), { surfaceOp: 'append' })
await ctx.tools.execute({
@@ -4015,7 +4031,7 @@ describe('dynamic nested workspace context injection', () => {
},
additionalContexts: [createUserMessage({
content: [{ type: 'text' as const, text: 'downstream context' }],
- source: { kind: 'plugin' as const, plugin: 'downstream' },
+ source: { kind: 'downstream' as const },
})],
}))
@@ -4049,7 +4065,7 @@ describe('dynamic nested workspace context injection', () => {
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'downstream context' }],
- source: { kind: 'plugin', plugin: 'downstream' },
+ source: { kind: 'downstream' },
})
} finally {
await rm(root, { recursive: true, force: true })
diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json
index 9fbcd7faf159..90146b08d875 100644
--- a/packages/context/file-reference-local/package.json
+++ b/packages/context/file-reference-local/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-file-reference-local",
"description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json
index 5cf131036d76..8d7e774887cb 100644
--- a/packages/context/file-reference/package.json
+++ b/packages/context/file-reference/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-file-reference",
"description": "File-reference discovery contract and shared @file grammar",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json
index f7142dba03f2..fd216204cce4 100644
--- a/packages/context/session-reference/package.json
+++ b/packages/context/session-reference/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-session-reference",
"description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/context/session-reference/src/projection.ts b/packages/context/session-reference/src/projection.ts
index d86e77bafb5b..c8fdf906492b 100644
--- a/packages/context/session-reference/src/projection.ts
+++ b/packages/context/session-reference/src/projection.ts
@@ -51,6 +51,7 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
if (text !== '') conversation.push({ role: 'assistant', text, checkpoint: false, originalText: text, omittedBytes: 0 })
break
}
+ case 'developer/message':
case 'system/message':
case 'tool/result':
break
diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts
index 00e68952d05d..88082745c1a8 100644
--- a/packages/context/session-reference/tests/session-reference.spec.ts
+++ b/packages/context/session-reference/tests/session-reference.spec.ts
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@mutantcat/cordis'
import { agentEvents, installModelSelection, type Agent, type ModelSelectionRef } from '@mutantcat/dsh-agent'
import { CompactionId, compactCheckpointSource } from '@mutantcat/dsh-compaction'
-import LlmRuntime, { createMessage, createSystemMessage, createToolResultMessage, createUserMessage, LlmError, ToolCallId } from '@mutantcat/dsh-llm'
+import LlmRuntime, { createDeveloperMessage, createMessage, createSystemMessage, createToolResultMessage, createUserMessage, LlmError, ToolCallId } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { Session, SessionId, SessionSeq } from '@mutantcat/dsh-session'
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import SessionQueryEngine from '@mutantcat/dsh-session-query'
@@ -19,6 +20,15 @@ import SessionReferenceResolver, {
import { stringifyTagSafeJson } from '../src/serialization.ts'
import { SpillLocator, SpillStore, type SaveTextSpill, type SpillRef } from '@mutantcat/dsh-spill'
+import type { MessageSource } from '@mutantcat/dsh-llm'
+
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ 'workspace': { kind: 'workspace' } & ContextFormed
+ }
+}
+
class TestSessionQueryEngine extends SessionQueryEngine {
override searchSessions(
..._args: Parameters
@@ -92,7 +102,7 @@ function checkpointSource(id: string) {
function appendConversation(session: Session): void {
session.append(
'system/message',
- { turn: 1, step: 1, message: createSystemMessage('system prompt secret', 'system-prompt') },
+ { turn: 1, step: 1, message: createSystemMessage('system prompt secret') },
{ surfaceOp: 'append' },
)
const oldUser = session.append(
@@ -140,7 +150,7 @@ function appendConversation(session: Session): void {
session.append(
'user/message',
createUserMessage({
- content: [{ type: 'text', text: 'workspace secret' }], source: { kind: 'plugin', plugin: 'workspace' },
+ content: [{ type: 'text', text: 'workspace secret' }], source: { kind: 'workspace' },
}),
{ surfaceOp: 'append' },
)
@@ -156,7 +166,7 @@ function appendConversation(session: Session): void {
'user/message',
createUserMessage({
content: [{ type: 'text', text: 'plugin steer' }],
- source: { kind: 'plugin', plugin: 'goal' },
+ source: { kind: 'goal' } as unknown as MessageSource,
}),
{ surfaceOp: 'append' },
)
@@ -192,7 +202,7 @@ function appendConversation(session: Session): void {
session.append(
'user/message',
createUserMessage({
- content: [{ type: 'text', text: 'plugin-generated user' }], source: { kind: 'plugin', plugin: 'goal' },
+ content: [{ type: 'text', text: 'plugin-generated user' }], source: { kind: 'goal' } as unknown as MessageSource,
}),
{ surfaceOp: 'append' },
)
@@ -863,7 +873,7 @@ describe('session reference discovery and preparation', () => {
})
const plugin = createUserMessage({
content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id, label: 'Ignored' }) }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})
const signal = new AbortController().signal
@@ -1007,6 +1017,12 @@ describe('session reference discovery and preparation', () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
+ source.append('developer/message', {
+ turn: 1, step: 1,
+ message: createDeveloperMessage({
+ content: [{ type: 'tool-removal', toolName: 'private_tool' }], source: { kind: 'test' },
+ }),
+ }, { surfaceOp: 'append' })
source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'nested referenced snapshot must not propagate' }],
source: {
diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json
index 81d85a3eb19c..4e57cba5d8e6 100644
--- a/packages/context/time-context/package.json
+++ b/packages/context/time-context/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-time-context",
"description": "Durable per-step context with the current time and elapsed time",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts
index 4f609fa3a628..33e24e275db0 100644
--- a/packages/context/time-context/src/index.ts
+++ b/packages/context/time-context/src/index.ts
@@ -10,6 +10,13 @@ import z from '@mutantcat/schemastery'
import { z as zod } from 'zod'
import type { Agent, PreStepDecision } from '@mutantcat/dsh-agent'
import { createUserMessage } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'time-context': { kind: 'time-context' } & ContextFormed
+ }
+}
+
import type { UserMessage } from '@mutantcat/dsh-llm'
import { SessionSeq } from '@mutantcat/dsh-session'
import type {} from '@mutantcat/dsh-session-projection'
@@ -160,7 +167,7 @@ export function apply(ctx: Context, config: Config): void {
return state.lastTurnInjectionTime === null ? state : { ...state, lastTurnInjectionTime: null }
}
if (event.type === 'user/message') {
- const injected = event.data.source.kind === 'plugin' && event.data.source.plugin === name
+ const injected = event.data.source.kind === name
const withMessage = state.lastMessageTime === event.time
? state
: { ...state, lastMessageTime: event.time }
@@ -214,7 +221,7 @@ export function apply(ctx: Context, config: Config): void {
...decision.messages,
createUserMessage({
content: [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
+ source: { kind: name, form: 'snapshot', sections: [{ name, text }] },
}),
],
}
diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts
index 31eb4e78bf96..532efbf33a5a 100644
--- a/packages/context/time-context/src/invariant.ts
+++ b/packages/context/time-context/src/invariant.ts
@@ -105,7 +105,7 @@ function validateReading(
}
const source = event.data.source
/* v8 ignore next 2 -- replay and dispatch callers select this exact package-owned source before validation. */
- if (source.kind !== 'plugin' || source.plugin !== SOURCE_NAME) {
+ if (source.kind !== SOURCE_NAME) {
fail('time-context source must retain package ownership')
}
const sections: unknown = 'sections' in source ? source.sections : undefined
@@ -113,7 +113,7 @@ function validateReading(
const section = typeof sectionValue === 'object' && sectionValue !== null
? sectionValue as Record
: undefined
- if (Object.keys(source).length !== 4
+ if (Object.keys(source).length !== 3
|| source.form !== 'snapshot'
|| !Array.isArray(sections)
|| sections.length !== 1
@@ -165,8 +165,7 @@ function validateSession(session: Session, fail: InvariantFailure): void {
const events = session.snapshotEvents()
for (const [index, event] of events.entries()) {
if (event.type !== 'user/message'
- || event.data.source.kind !== 'plugin'
- || event.data.source.plugin !== SOURCE_NAME) continue
+ || event.data.source.kind !== SOURCE_NAME) continue
validateReading(events.slice(0, index), event, fail)
}
}
@@ -179,8 +178,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (event.type !== 'user/message'
- || event.data.source.kind !== 'plugin'
- || event.data.source.plugin !== SOURCE_NAME) return
+ || event.data.source.kind !== SOURCE_NAME) return
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
validateReading(session.snapshotEvents(), event, fail)
}, { global: true })
diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts
index 78f001d9b0f5..1dd61b4d0e27 100644
--- a/packages/context/time-context/tests/invariant.spec.ts
+++ b/packages/context/time-context/tests/invariant.spec.ts
@@ -31,12 +31,11 @@ function event(
content: (content ?? [{ type: 'text', text }]) as ContentBlock[],
source: plugin === 'time-context'
? {
- kind: 'plugin',
- plugin,
+ kind: 'time-context',
form: 'snapshot',
sections: [{ name: plugin, text }],
}
- : { kind: 'plugin', plugin },
+ : { kind: 'other' },
}),
}
}
@@ -78,8 +77,7 @@ function appendReading(session: Session, text: string): void {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: {
- kind: 'plugin',
- plugin: 'time-context',
+ kind: 'time-context',
form: 'snapshot',
sections: [{ name: 'time-context', text }],
},
@@ -287,23 +285,20 @@ describe('time-context invariants', () => {
const ctx = await setup()
const base = event(reading())
for (const source of [
- { kind: 'plugin', plugin: 'time-context' },
+ { kind: 'time-context' },
{ ...base.data.source, authority: {} },
{
- kind: 'plugin',
- plugin: 'time-context',
+ kind: 'time-context',
form: 'snapshot',
sections: [{ name: 'time-context', text: 'different' }],
},
{
- kind: 'plugin',
- plugin: 'time-context',
+ kind: 'time-context',
form: 'snapshot',
sections: { 0: { name: 'time-context', text: reading() }, length: 1 },
},
{
- kind: 'plugin',
- plugin: 'time-context',
+ kind: 'time-context',
form: 'snapshot',
sections: [{ name: 'time-context', text: reading(), extra: true }],
},
diff --git a/packages/context/time-context/tests/request-zone.spec.ts b/packages/context/time-context/tests/request-zone.spec.ts
index 317f265fcb0c..072cb152faa4 100644
--- a/packages/context/time-context/tests/request-zone.spec.ts
+++ b/packages/context/time-context/tests/request-zone.spec.ts
@@ -1,11 +1,18 @@
import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@mutantcat/dsh-llm'
import type { UserMessage } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import {
deriveBrowserTimeZoneContext,
renderBrowserTimeZoneContext,
} from '../src/request-zone.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
function browserMessage(timeZone: string): UserMessage {
return createUserMessage({
content: [{ type: 'text', text: timeZone }],
@@ -17,7 +24,7 @@ describe('browser request-zone context', () => {
it('derives missing, unique, and sorted mixed zones from user-rpc messages only', () => {
const plugin = createUserMessage({
content: [{ type: 'text', text: 'plugin' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})
expect(deriveBrowserTimeZoneContext([plugin])).toEqual({ kind: 'missing' })
expect(deriveBrowserTimeZoneContext([
diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts
index a818f3264a9b..74e3c52cf656 100644
--- a/packages/context/time-context/tests/time-context.e2e.ts
+++ b/packages/context/time-context/tests/time-context.e2e.ts
@@ -50,8 +50,7 @@ describe('time-context through the production headless profile', () => {
const contexts = events.filter(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === 'time-context')
+ && event.data.source.kind === 'time-context')
const starts = events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(2)
expect(starts).toHaveLength(2)
@@ -61,8 +60,7 @@ describe('time-context through the production headless profile', () => {
// `snapshot` form: one named contribution whose text is exactly what the
// model read, so a consumer attributes it without re-splitting prose.
expect(contexts[index]!.data.source).toMatchObject({
- kind: 'plugin',
- plugin: 'time-context',
+ kind: 'time-context',
form: 'snapshot',
sections: [{ name: 'time-context' }],
})
diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts
index ec4699d9c9ed..7110cfab7079 100644
--- a/packages/context/time-context/tests/time-context.spec.ts
+++ b/packages/context/time-context/tests/time-context.spec.ts
@@ -3,6 +3,7 @@ import { Context } from '@mutantcat/cordis'
import Loader from '@mutantcat/cordis-plugin-loader'
import { createUserMessage, ToolCallId, LlmAdapter } from '@mutantcat/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import { Session, SessionId, type SessionEvent } from '@mutantcat/dsh-session'
import AgentRegistry, { agentEvents, type Agent } from '@mutantcat/dsh-agent'
import { defineContentToolFixture } from '@mutantcat/dsh-tools'
@@ -12,6 +13,13 @@ import { unsupportedInbox, mountAgentLoopTestDependencies } from '@mutantcat/dsh
import * as timeContext from '@mutantcat/dsh-time-context'
import type { Config } from '@mutantcat/dsh-time-context'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'compaction-basic': { kind: 'compaction-basic' } & ContextFormed
+ 'time-context-test': { kind: 'time-context-test' } & ContextFormed
+ }
+}
+
const BASE = Date.parse('2026-07-14T00:00:00.000Z')
const ORIGINAL_TIME_ZONE = process.env['TZ']
const SIGNAL = new AbortController().signal
@@ -50,6 +58,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
steer: () => {},
inject: () => { throw new Error('time-context must append directly to the open step') },
cancel() {},
+ interruptModelStep: () => false,
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
@@ -70,8 +79,7 @@ function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.snapshotEvents()) {
if (event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === 'time-context') {
+ && event.data.source.kind === 'time-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
}
}
@@ -87,7 +95,7 @@ async function fire(
): Promise {
const proposed = createUserMessage({
content: [{ type: 'text', text: 'request proposal' }],
- source: { kind: 'plugin', plugin: 'time-context-test' },
+ source: { kind: 'time-context-test' },
})
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
@@ -175,8 +183,7 @@ describe('durable step context', () => {
// text is exactly what the model read, so a consumer attributes it without
// re-splitting prose.
expect(event.data.source).toEqual({
- kind: 'plugin',
- plugin: 'time-context',
+ kind: 'time-context',
form: 'snapshot',
sections: [{
name: 'time-context',
@@ -292,11 +299,11 @@ describe('durable step context', () => {
openMessageTurn(original, 1)
await fire(ctx, sessionAgent(original), 1, 1)
const user = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user')
- const reading = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
+ const reading = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'time-context')
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
original.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted history' }],
- source: { kind: 'plugin', plugin: 'compaction-basic' },
+ source: { kind: 'compaction-basic' },
}), {
surfaceOp: { op: 'replace', startSeq: user.seq, endSeq: reading.seq },
sourceEventSeqs: [user.seq, reading.seq],
@@ -421,7 +428,7 @@ describe('time-context projection fold edges', () => {
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'reading' }],
- source: { kind: 'plugin', plugin: 'time-context' },
+ source: { kind: 'time-context' },
}), { surfaceOp: 'append' })
expect(typeof ctx.sessionProjections.stateOf(session, 'timeContext')?.lastTurnInjectionTime).toBe('number')
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -483,15 +490,14 @@ describe('real agent-loop request history', () => {
expect(adapter.requests).toHaveLength(2)
const contexts = agent.session.snapshotEvents().filter(
- (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
+ (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')
const starts = agent.session.snapshotEvents().filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(adapter.requests.length)
expect(starts).toHaveLength(adapter.requests.length)
for (let index = 0; index < contexts.length; index += 1) {
expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq)
}
- expect(contexts.every(event => event.data.source.kind === 'plugin'
- && event.data.source.plugin === 'time-context'
+ expect(contexts.every(event => event.data.source.kind === 'time-context'
&& event.surfaceOp === 'append')).toBe(true)
const firstRequestText = requestText(adapter.requests[0]!)
diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json
index 3fa86757c59b..eceed490a6fb 100644
--- a/packages/context/tmux-context/package.json
+++ b/packages/context/tmux-context/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-tmux-context",
"description": "Opt-in durable per-step context with this agent's tmux pane and window location",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts
index b46a07928b8c..fe1c77066fc3 100644
--- a/packages/context/tmux-context/src/index.ts
+++ b/packages/context/tmux-context/src/index.ts
@@ -25,6 +25,17 @@ import type { PreStepDecision } from '@mutantcat/dsh-agent'
import type {} from '@mutantcat/dsh-session-projection'
import type { ShellExecutor, ShellRunResult } from '@mutantcat/dsh-shell'
import { createUserMessage } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ /** Location attribution; readers preserve the content without this producer.
+ * Its projection uses the kind to avoid repeated injection.
+ * @persistenceAttribution
+ */
+ 'tmux-context': { kind: 'tmux-context' } & ContextFormed
+ }
+}
+
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tmux-context'
@@ -223,8 +234,7 @@ export function apply(ctx: Context, config: Config): void {
init: () => null,
apply: (state, event) => {
if (event.type !== 'user/message'
- || event.data.source.kind !== 'plugin'
- || event.data.source.plugin !== name) return state
+ || event.data.source.kind !== name) return state
const [block] = event.data.content
if (block?.type !== 'text') return state
const newline = block.text.indexOf('\n')
@@ -256,7 +266,7 @@ export function apply(ctx: Context, config: Config): void {
messages: [
createUserMessage({
content: [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
+ source: { kind: name, form: 'snapshot', sections: [{ name, text }] },
}),
...decision.messages,
],
diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts
index 24e9037c614c..8c2609061627 100644
--- a/packages/context/tmux-context/tests/tmux-context.spec.ts
+++ b/packages/context/tmux-context/tests/tmux-context.spec.ts
@@ -107,6 +107,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
steer: () => {},
inject: () => { throw new Error('tmux-context must append directly to the open step') },
cancel() {},
+ interruptModelStep: () => false,
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
@@ -125,8 +126,7 @@ function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.snapshotEvents()) {
if (event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === 'tmux-context') {
+ && event.data.source.kind === 'tmux-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
}
}
@@ -176,8 +176,7 @@ describe('tmux-context injection', () => {
// `snapshot` form: one named contribution carrying exactly the reading the
// model saw, so a consumer attributes it without re-splitting prose.
expect(event.data.source).toMatchObject({
- kind: 'plugin',
- plugin: 'tmux-context',
+ kind: 'tmux-context',
form: 'snapshot',
sections: [{ name: 'tmux-context' }],
})
@@ -275,7 +274,7 @@ describe('tmux-context prior-reading resilience', () => {
openMessageTurn(session, 1)
session.append('user/message', createUserMessage({
content: [{ type: 'reasoning', text: 'not a location' }],
- source: { kind: 'plugin', plugin: 'tmux-context' },
+ source: { kind: 'tmux-context' },
}), { surfaceOp: 'append' })
await fire(ctx, agent, 1, 1)
@@ -291,7 +290,7 @@ describe('tmux-context prior-reading resilience', () => {
openMessageTurn(session, 1)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'single line, no newline' }],
- source: { kind: 'plugin', plugin: 'tmux-context' },
+ source: { kind: 'tmux-context' },
}), { surfaceOp: 'append' })
await fire(ctx, agent, 1, 1)
diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json
index dd9c2120bcb3..748922cb297c 100644
--- a/packages/core/agent-default-model/package.json
+++ b/packages/core/agent-default-model/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-agent-default-model",
"description": "Default model selection shared by Agent entry points",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts
index 5c73535041ee..3e09dbb7a645 100644
--- a/packages/core/agent-default-model/src/index.ts
+++ b/packages/core/agent-default-model/src/index.ts
@@ -67,6 +67,8 @@ function selection(settings: AgentDefaultModelSettings): ModelSelection | null {
* is mounted, its user layer is read live.
*/
export class AgentDefaultModelConfig extends Service {
+ static inject = ['settings']
+
static Config: z = z.object({
provider: z.string().required(false),
model: z.string().required(false),
diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml
index 80ec31475eae..3e5e7235536e 100644
--- a/packages/core/agent-loop/README.i18n.yaml
+++ b/packages/core/agent-loop/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
-README.md: 7b6e29e8ec57dc69c9b6a0258a02379e3bd0f8a2
-README.zh.md: 8cd93dd00abf535db1e9348d02e9815c01541298
+README.md: f39ae95fbc708e89414ab8686da62f2abc6ce0c3
+README.zh.md: c83b8387c53de5719eb1b063855a0caf9fe20e03
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index 7b6e29e8ec57..f39ae95fbc70 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -72,7 +72,7 @@ Every inbox mutation commits one normalized `agent/inbox/spliced` event. The pro
### What a step does
-Each step sends the session's derived history — with the latest non-empty `system/message` node as the effective prompt, or no system messages when the rendered prompt is empty — and its visible tool schemas; the model's tool calls run through the guarded tool pipeline and every accepted fact is appended to the session log before the next step derives from it. Parallel-safe calls may overlap up to `maxParallelToolCalls`; exclusive calls run alone as ordering barriers. Cancellation is cooperative: `agent.cancel()` aborts the current activity and, unless `keepInbox` is set, clears pending work; a cancelled stream finalizes the text already delivered to the user.
+Each step sends the session's derived history — with the latest non-empty `system/message` node as the effective prompt, or no system messages when the rendered prompt is empty — and its visible tool schemas; the model's tool calls run through the guarded tool pipeline and every accepted fact is appended to the session log before the next step derives from it. Parallel-safe calls may overlap up to `maxParallelToolCalls`; exclusive calls run alone as ordering barriers. Cancellation is cooperative: `agent.cancel()` aborts the current activity and, unless `keepInbox` is set, clears pending work; a cancelled stream finalizes the text already delivered to the user. `agent.interruptModelStep()` is the step-scoped counterpart: it cuts only the in-flight provider wait, finalizes that delivered prefix the same way, and admits the next step of the same turn, so a selection installed meanwhile — a model switch requested while the turn runs — applies immediately instead of at the next turn. It reports `false` when no model step is waiting on its provider.
-----
diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md
index 8cd93dd00abf..c83b8387c53d 100644
--- a/packages/core/agent-loop/README.zh.md
+++ b/packages/core/agent-loop/README.zh.md
@@ -72,7 +72,7 @@ const handle = await ctx.agents.create({
### 一个步骤做什么
-每个步骤都会发送会话的派生历史——最新的非空 `system/message` 节点是有效提示词,渲染提示词为空时则没有系统消息——及其可见工具 schema;模型的工具调用经过受守卫的工具流水线,每个被接纳的事实都会在下一步据此派生之前追加到会话日志。并行安全调用最多可重叠 `maxParallelToolCalls` 个;独占调用单独运行并构成排序屏障。取消是协作式的:`agent.cancel()` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;被取消的流会终结已送达用户的文本。
+每个步骤都会发送会话的派生历史——最新的非空 `system/message` 节点是有效提示词,渲染提示词为空时则没有系统消息——及其可见工具 schema;模型的工具调用经过受守卫的工具流水线,每个被接纳的事实都会在下一步据此派生之前追加到会话日志。并行安全调用最多可重叠 `maxParallelToolCalls` 个;独占调用单独运行并构成排序屏障。取消是协作式的:`agent.cancel()` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;被取消的流会终结已送达用户的文本。`agent.interruptModelStep()` 是步骤级的对应物:它只切断正在等待的提供方调用,以同样方式终结已交付的前缀,并接纳同一轮次的下一步,因此期间装入的选择——会话运行中请求的模型切换——会立刻生效,而不必等到下一轮。没有模型步骤正在等待提供方时返回 `false`。
-----
diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json
index 2699088362c5..c9f7ea83c70f 100644
--- a/packages/core/agent-loop/package.json
+++ b/packages/core/agent-loop/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-agent-loop",
"description": "The concrete agent loop plugin for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index e2cf859c1f3b..7f08fd0ba460 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -89,6 +89,11 @@ export class ReactLoopAgent implements Agent {
/** Process-local revision of assistant frames for this attached Session. */
private assistantStreamRevision = 0
private assistantAttemptCounter = 0
+ /**
+ * The in-flight model step's provider wait. Present only while a step awaits
+ * its provider, so an interrupt asked for between steps cuts nothing.
+ */
+ private stepInterrupt: AbortController | null = null
private readonly systemPrompt: SystemPromptProjection
/** Identities fully frozen by this loop; weak references do not retain replaced history. */
private readonly frozenMessages = new WeakSet()
@@ -154,6 +159,13 @@ export class ReactLoopAgent implements Agent {
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
}
+ interruptModelStep(): boolean {
+ const step = this.stepInterrupt
+ if (step === null || step.signal.aborted) return false
+ step.abort()
+ return true
+ }
+
runMaintenance(job: (signal: AbortSignal) => Promise): Promise {
if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`)
const done = Promise.withResolvers()
@@ -377,7 +389,15 @@ export class ReactLoopAgent implements Agent {
}
}
firstAttempt = false
- const request = this.buildRequest(config, preparedCall, assembly.tools, startsRequestSeries, signal)
+ // Cutting this wait short keeps the turn: the delivered prefix settles as
+ // an interrupted assistant message and the loop admits the next step,
+ // which assembles its request from the selection installed meanwhile. The
+ // step interrupt reaches the provider by joining it into the request
+ // signal, so a provider still waiting on its first chunk is cut too.
+ const interrupt = new AbortController()
+ const request = this.buildRequest(
+ config, preparedCall, assembly.tools, startsRequestSeries, AbortSignal.any([signal, interrupt.signal]),
+ )
const live = new AssistantStreamAttempt(
this.session.id,
++this.assistantAttemptCounter,
@@ -390,17 +410,19 @@ export class ReactLoopAgent implements Agent {
try {
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
signal.throwIfAborted()
+ this.stepInterrupt = interrupt
live.start()
started = true
for await (const chunk of stream) {
signal.throwIfAborted()
+ interrupt.signal.throwIfAborted()
live.push(chunk)
}
signal.throwIfAborted()
} catch (error: unknown) {
if (!started) throw error
try {
- if (signal.aborted) {
+ if (signal.aborted || interrupt.signal.aborted) {
const content = live.interruptedBlocks()
if (content.length > 0) {
live.settle('assistant/message', () => this.session.append('assistant/message', {
@@ -437,7 +459,12 @@ export class ReactLoopAgent implements Agent {
{ cause: error },
)
}
+ // A step interrupt is not a turn cancellation: the turn continues to
+ // its next step, which is what makes a mid-turn model switch apply now.
+ if (interrupt.signal.aborted && !signal.aborted) return null
throw error
+ } finally {
+ if (this.stepInterrupt === interrupt) this.stepInterrupt = null
}
try {
const finish = live.finish
diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts
index 3d732d7a1710..75c10603b1a5 100644
--- a/packages/core/agent-loop/src/index.ts
+++ b/packages/core/agent-loop/src/index.ts
@@ -34,6 +34,7 @@ import type { SessionHandle, SessionPersistence } from '@mutantcat/dsh-session-p
import { ReactLoopAgent } from './agent.ts'
import { inboxProjectionDefinition } from './inbox.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
+import type {} from './runtime-context.ts'
/** Fiber states that cannot own or serve a new lifecycle. */
const INACTIVE_STATES: ReadonlySet = new Set([
diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts
index 701d34c6c605..8a26e280d901 100644
--- a/packages/core/agent-loop/src/runtime-context.ts
+++ b/packages/core/agent-loop/src/runtime-context.ts
@@ -6,16 +6,21 @@
*/
import { createSystemMessage, createUserMessage } from '@mutantcat/dsh-llm'
-import type { ContextSnapshotSection, Message } from '@mutantcat/dsh-llm'
+import type { ContextFormed, ContextSnapshotSection, Message } from '@mutantcat/dsh-llm'
import type { Session, SessionEvent, SessionSeq, SurfaceIntent, SystemMessage, UserMessage } from '@mutantcat/dsh-session'
import { isReplacementSurfaceEvent } from '@mutantcat/dsh-session'
import type { Context } from '@mutantcat/cordis'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'runtime-context': { kind: 'runtime-context' } & ContextFormed
+ }
+}
-const SOURCE = '@mutantcat/dsh-system-prompt'
+const SOURCE = 'runtime-context'
const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
function isOwned(message: UserMessage): boolean {
- return message.source.kind === 'plugin' && message.source.plugin === SOURCE
+ return message.source.kind === SOURCE
}
function textOf(message: Message): string | undefined {
@@ -84,7 +89,7 @@ export class SystemPromptProjection {
const nodes = this.systemNodes()
const head = nodes[0]
if (head === undefined) {
- return [{ message: createSystemMessage(rendered, SOURCE), intent: { surfaceOp: 'append' } }]
+ return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
}
const latest = nodes.findLast(node => node.text !== '') ?? head
if (!input.inHistory || input.startsSeries || rendered.length === 0) {
@@ -94,12 +99,12 @@ export class SystemPromptProjection {
return updates
}
if (latest.text === rendered) return []
- return [{ message: createSystemMessage(rendered, SOURCE), intent: { surfaceOp: 'append' } }]
+ return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
}
private replace(seq: SessionSeq, text: string): SystemPromptCommit {
return {
- message: createSystemMessage(text, SOURCE),
+ message: createSystemMessage(text),
intent: { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] },
}
}
@@ -152,8 +157,8 @@ export class RuntimeContextProjection {
content: [{ type: 'text', text: snapshot }],
// The cleared marker has no contributions left to attribute.
source: sections.length === 0
- ? { kind: 'plugin', plugin: SOURCE }
- : { kind: 'plugin', plugin: SOURCE, form: 'snapshot', sections },
+ ? { kind: SOURCE }
+ : { kind: SOURCE, form: 'snapshot', sections },
})
}
}
diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts
index 96b8e8dc1eb2..34cfffe58dcb 100644
--- a/packages/core/agent-loop/tests/agent-initiator.spec.ts
+++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts
@@ -196,7 +196,10 @@ describe('AgentLoop initiator scope', () => {
await firstIdle
const firstSignal = signals[0]
expect(firstSignal).toBeDefined()
- expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
+ expect(new Set(signals)).toEqual(new Set([firstSignal]))
+ // A provider request carries the turn signal joined with that step's
+ // interrupt, so it stays bound to the turn instead of replacing it.
+ for (const request of adapter.requests.slice(0, 2)) expect(request.signal?.aborted).toBe(false)
expect(preStepSignals).toHaveLength(2)
expect(new Set(preStepSignals)).toEqual(new Set([firstSignal]))
@@ -207,7 +210,8 @@ describe('AgentLoop initiator scope', () => {
await secondIdle
const secondSignal = signals[0]
expect(secondSignal).toBeDefined()
- expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
+ expect(new Set(signals)).toEqual(new Set([secondSignal]))
+ expect(adapter.requests[2]!.signal?.aborted).toBe(false)
expect(preStepSignals).toHaveLength(1)
expect(preStepSignals[0]).toBe(secondSignal)
expect(secondSignal).not.toBe(firstSignal)
diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts
index ee32b3627d06..3fbf4ee40d43 100644
--- a/packages/core/agent-loop/tests/agent.spec.ts
+++ b/packages/core/agent-loop/tests/agent.spec.ts
@@ -5,11 +5,20 @@ import AgentRegistry, { type Agent } from '@mutantcat/dsh-agent'
import AgentLoop from '@mutantcat/dsh-agent-loop'
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import LlmRuntime from '@mutantcat/dsh-llm'
+import type { MessageSource } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { SessionId } from '@mutantcat/dsh-session'
import SystemPrompt from '@mutantcat/dsh-system-prompt'
import ToolRuntime from '@mutantcat/dsh-tools'
import { MockAdapter, textResponse } from './mock-adapter.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'p': { kind: 'p' } & ContextFormed
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
async function harness(adapter: MockAdapter): Promise {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
@@ -33,7 +42,7 @@ describe('Agent', () => {
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
- agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'p' } }))
expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['agent/inbox/spliced'])
expect(agent.status).toBe('idle')
@@ -41,15 +50,16 @@ describe('Agent', () => {
await agent.whenIdle()
})
- it('inject() preserves an explicitly empty plugin source', async () => {
+ it('inject() preserves an unknown producer source', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
- agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } }))
+ const source = { kind: 'unknown-producer' } as unknown as MessageSource
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source }))
const injected = agent.session.snapshotEvents().at(-1)
expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source)
- .toEqual({ kind: 'plugin', plugin: '' })
+ .toEqual({ kind: 'unknown-producer' })
})
it('emits exact inserted, claimed, and discarded inbox messages', async () => {
@@ -76,7 +86,7 @@ describe('Agent', () => {
})
const context = createUserMessage({
content: [{ type: 'text', text: 'discard me' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})
agent.inject(context)
agent.inbox.remove(context.id)
@@ -95,7 +105,7 @@ describe('Agent', () => {
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
expect(() => {
- agent.inject(createUserMessage({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'p' } }))
}).toThrow(/non-JSON-serializable/)
expect(agent.session.snapshotEvents()).toHaveLength(0)
})
@@ -105,7 +115,7 @@ describe('Agent', () => {
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
- agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } }))
+ agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'test' } }))
await agent.whenIdle()
expect(agent.session.snapshotEvents().some(event => event.type === 'user/message')).toBe(true)
diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts
index ea08426ff207..2d860f667bf7 100644
--- a/packages/core/agent-loop/tests/cancel.spec.ts
+++ b/packages/core/agent-loop/tests/cancel.spec.ts
@@ -441,17 +441,17 @@ describe('Agent.cancel()', () => {
expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
message: {
+ role: 'tool',
source: { kind: 'tool', callId: 'c1' },
- content: [{ type: 'tool-result', toolCallId: 'c1', isError: true }],
+ content: [{ type: 'text' }],
+ isError: true,
},
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
send(agent, 'continue safely')
await waitForIdle(ctx, agent)
- const replayedResult = adapter.requests[1]!.messages
- .flatMap(message => message.content)
- .find(block => block.type === 'tool-result')
+ const replayedResult = adapter.requests[1]!.messages.find(message => message.role === 'tool')
expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
expect(reasons).toEqual([
{ kind: 'aborted', reason: { kind: 'user' } },
diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts
index 63b4f555d151..9b7779ae32b7 100644
--- a/packages/core/agent-loop/tests/config-session-id.spec.ts
+++ b/packages/core/agent-loop/tests/config-session-id.spec.ts
@@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmRuntime from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { SessionId } from '@mutantcat/dsh-session'
import type { SessionEvent } from '@mutantcat/dsh-session'
import SystemPrompt from '@mutantcat/dsh-system-prompt'
@@ -17,6 +18,12 @@ import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@mutantcat/dsh-agent
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import { MockAdapter, textResponse } from './mock-adapter.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
@@ -214,7 +221,7 @@ describe('config-driven session id', () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
- first.inject(createUserMessage({ content: [{ type: 'text', text: 'persist before cancellation' }], source: { kind: 'plugin', plugin: 'test' } }))
+ first.inject(createUserMessage({ content: [{ type: 'text', text: 'persist before cancellation' }], source: { kind: 'test' } }))
await ctx.sessions.flush(first.session)
expect(JSON.stringify(await readStoredEvents(ctx, sessionId)))
.toContain('persist before cancellation')
diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts
index 0d9823b6e5ab..46738b3ee4e9 100644
--- a/packages/core/agent-loop/tests/contract-regressions.spec.ts
+++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@mutantcat/cordis'
-import LlmRuntime, { createUserMessage, ToolCallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@mutantcat/dsh-llm'
+import LlmRuntime, { type RequestMessage, createUserMessage, ToolCallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@mutantcat/dsh-session'
import SystemPrompt from '@mutantcat/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture, type PostToolDecision } from '@mutantcat/dsh-tools'
@@ -13,6 +14,12 @@ import * as AgentInvariant from '@mutantcat/dsh-agent/invariant'
import * as AgentLoopInvariant from '@mutantcat/dsh-agent-loop/invariant'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
async function mountInvariants(ctx: Context): Promise {
await ctx.plugin(InvariantRegistry)
await ctx.plugin(SessionInvariant)
@@ -95,7 +102,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute() {
- agent.inject(createUserMessage({ content: [{ type: 'text', text: 'accepted before abort' }], source: { kind: 'plugin', plugin: 'test' } }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'accepted before abort' }], source: { kind: 'test' } }))
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
@@ -104,7 +111,7 @@ describe('abort during tool execution ends the turn', () => {
kind: 'accept',
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'accepted result context after abort' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})],
}))
@@ -113,7 +120,7 @@ describe('abort during tool execution ends the turn', () => {
expect(agent.session.snapshotEvents()
.filter(event => event.type === 'tool/result'
- || (event.type === 'user/message' && event.data.source.kind === 'plugin')
+ || (event.type === 'user/message' && event.data.source.kind !== 'user')
|| event.type === 'step/end' || event.type === 'turn/end')
.map(event => event.type))
.toEqual(['tool/result', 'step/end', 'turn/end'])
@@ -125,7 +132,7 @@ describe('abort during tool execution ends the turn', () => {
await idle
expect(agent.session.snapshotEvents()
- .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
+ .flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'user'
? [event.data.content]
: []))
.toEqual([
@@ -166,7 +173,7 @@ describe('abort during tool execution ends the turn', () => {
kind: 'accept',
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'accepted after first result' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})],
}
})
@@ -177,12 +184,12 @@ describe('abort during tool execution ends the turn', () => {
const events = agent.session.snapshotEvents()
expect(events
.filter(event => event.type === 'tool/result'
- || (event.type === 'user/message' && event.data.source.kind === 'plugin')
+ || (event.type === 'user/message' && event.data.source.kind !== 'user')
|| event.type === 'step/end' || event.type === 'turn/end')
.map(event => event.type))
.toEqual(['tool/result', 'tool/result', 'step/end', 'turn/end'])
expect(events.flatMap(event =>
- event.type === 'user/message' && event.data.source.kind === 'plugin'
+ event.type === 'user/message' && event.data.source.kind !== 'user'
? [event.data.content]
: [])[0])
.toBeUndefined()
@@ -220,7 +227,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute(_args, exec) {
- agent.inject(createUserMessage({ content: [{ type: 'text', text: 'accepted before disposal' }], source: { kind: 'plugin', plugin: 'test' } }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'accepted before disposal' }], source: { kind: 'test' } }))
started.resolve(undefined)
const signal = exec.signal
if (!signal) throw new Error('tool execution signal is missing')
@@ -235,7 +242,7 @@ describe('abort during tool execution ends the turn', () => {
kind: 'accept',
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'accepted result context during disposal' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})],
}))
@@ -244,7 +251,7 @@ describe('abort during tool execution ends the turn', () => {
await fiber.dispose()
expect(agent.session.snapshotEvents()
- .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
+ .flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'user'
? [event.data.content]
: []))
.toEqual([])
@@ -301,7 +308,7 @@ describe('abort during tool execution ends the turn', () => {
kind: 'enter' as const,
messages: [...decision.messages, createUserMessage({
content: [{ type: 'text', text: 'new turn context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})],
}
}
@@ -311,7 +318,7 @@ describe('abort during tool execution ends the turn', () => {
await waitForIdle(ctx, agent)
expect(agent.session.snapshotEvents().flatMap(event =>
- event.type === 'user/message' && event.data.source.kind === 'plugin'
+ event.type === 'user/message' && event.data.source.kind !== 'user'
? [event.data.content]
: [])[0])
.toEqual([{ type: 'text', text: 'new turn context' }])
@@ -481,7 +488,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
description: '',
parameters: {},
async execute() {
- agent.steer(createUserMessage({ content: [{ type: 'text', text: 's' }], source: { kind: 'plugin', plugin: 'goal' } }))
+ agent.steer(createUserMessage({ content: [{ type: 'text', text: 's' }], source: { kind: 'goal' } as unknown as MessageSource }))
return []
},
}))
@@ -503,7 +510,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
expect(insertedSources).toEqual([
{ kind: 'user' },
- { kind: 'plugin', plugin: 'goal' },
+ { kind: 'goal' },
])
expect(insertedShapes).toEqual([
['content', 'id', 'role', 'source'],
@@ -511,8 +518,8 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
])
expect(targets).toEqual(['next-turn', 'next-step'])
const steeringSources = agent.session.snapshotEvents().flatMap(e =>
- e.type === 'user/message' && e.data.source.kind === 'plugin' ? [e.data.source] : [])
- expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
+ e.type === 'user/message' && e.data.source.kind !== 'user' ? [e.data.source] : [])
+ expect(steeringSources).toEqual([{ kind: 'goal' }])
})
it('records each admitted next-step batch before the following claim', async () => {
@@ -603,6 +610,152 @@ describe('turn numbering continues across seeded sessions', () => {
expect(turns).toEqual([2])
})
+
+ it('an agent over a mid-turn fork seed continues past the synthetic forked closer as a resume', async () => {
+ const adapter = new MockAdapter([textResponse('branched reply')])
+ const ctx = await harness(adapter)
+ // A source cut open mid-turn: turn 1 completed under a logged header, turn 2
+ // claimed its prompt but never closed.
+ const source = ctx.sessions.create(SessionId('mid-turn-source'))
+ source.append('turn/start', { turn: 1 })
+ source.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
+ }), { surfaceOp: 'append' })
+ source.append('request/header', {
+ header: { config: { provider: 'mock', model: 'mock' } },
+ reason: 'initial',
+ })
+ source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ source.append('turn/start', { turn: 2 })
+ source.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' },
+ }), { surfaceOp: 'append' })
+ const boundary = source.snapshotEvents().at(-1)!.seq
+
+ const child = ctx.sessions.fork(source, boundary, SessionId('mid-turn-child'))
+ const closer = child.snapshotEvents().at(-1)
+ expect(closer?.type === 'turn/end' && closer.data.reason).toEqual({ kind: 'forked' })
+
+ const { agent: forked } = await ctx.agents.create({
+ sessionId: SessionId('mid-turn-agent'), seed: child.snapshotEvents(),
+ inheritedEventCount: child.inheritedEventCount, meta: { isSeeded: true },
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
+ const turns: number[] = []
+ const headerReasons: string[] = []
+ ctx.on('session/event', (session, event) => {
+ if (session !== forked.session) return
+ if (event.type === 'turn/start') turns.push(event.data.turn)
+ if (event.type === 'request/header') headerReasons.push(event.data.reason)
+ })
+ forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
+ await waitForIdle(ctx, forked)
+
+ // The synthetic closer ended turn 2, so the branch's first live turn is 3,
+ // and its first request over the seeded log is a resume, not a new header.
+ expect(turns).toEqual([3])
+ expect(headerReasons).toEqual(['resume'])
+ const reply = forked.session.deriveMessages().at(-1)
+ expect(reply?.role).toBe('assistant')
+ expect(reply?.content).toEqual([{ type: 'text', text: 'branched reply' }])
+ })
+})
+
+/** Assert the call/result ordering delivered to the model, including missing results. */
+function expectPairedTools(messages: readonly RequestMessage[]): void {
+ const pending = new Set()
+ for (const message of messages) {
+ if (pending.size > 0) expect(message.role).toBe('tool')
+ if (message.role === 'tool') {
+ expect(message.source.kind).toBe('tool')
+ expect(pending.delete(message.toolCallId)).toBe(true)
+ continue
+ }
+ for (const block of message.content) {
+ if (block.type === 'tool-call') {
+ expect(pending.has(block.id)).toBe(false)
+ pending.add(block.id)
+ }
+ }
+ }
+ expect([...pending]).toEqual([])
+}
+
+describe('forked tool history reaches the next model request', () => {
+ it.each([
+ ['assistant/message', 0, 2],
+ ['tool/call', 0, 2],
+ ['tool/result', 0, 1],
+ ['tool/result', 1, 0],
+ ['step/end', 0, 0],
+ ] as const)('cuts after %s[%i] and supplies %i missing results', async (type, occurrence, missing) => {
+ const first = toolCallResponse('call-first', 'first', {}).slice(0, -2)
+ const second = toolCallResponse('call-second', 'second', {}).map(chunk => (
+ 'index' in chunk ? { ...chunk, index: 1 } : chunk
+ ))
+ const adapter = new MockAdapter([
+ [...first, ...second], textResponse('parent complete'), textResponse('child complete'),
+ ])
+ const ctx = await harness(adapter)
+ try {
+ await mountInvariants(ctx)
+ const executions: string[] = []
+ for (const name of ['first', 'second']) {
+ ctx.tools.register(defineContentToolFixture({
+ name, description: '', parameters: {},
+ execute: () => {
+ executions.push(name)
+ return Promise.resolve([{ type: 'text', text: `${name} result` }])
+ },
+ }))
+ }
+ const parent = await ctx.agentLoop.create(SessionId('paired-parent'), { provider: 'mock', model: 'mock' })
+ send(parent, 'run both tools')
+ await waitForIdle(ctx, parent)
+ const source = parent.session.snapshotEvents()
+ const boundary = source.filter(event => event.type === type)[occurrence]
+ if (boundary === undefined) throw new Error(`parent has no ${type}[${occurrence}]`)
+ expect(source.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
+ expectPairedTools(adapter.requests[1]!.messages)
+
+ const child = ctx.sessions.fork(parent.session, boundary.seq, SessionId('paired-child'))
+ expect(child.snapshotEvents().slice(0, boundary.seq + 1)).toEqual(source.slice(0, boundary.seq + 1))
+ const closers = child.snapshotEvents().slice(boundary.seq + 1, child.firstLiveSeq)
+ const results = closers.filter(event => event.type === 'tool/result')
+ expect(results).toHaveLength(missing)
+ expect(closers.filter(event => event.type === 'step/end')).toHaveLength(type === 'step/end' ? 0 : 1)
+ expect(closers.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'forked' } } })
+ for (const result of results) {
+ const text = result.data.message.content[0]
+ if (text?.type !== 'text') throw new Error('fork result has no text')
+ expect(text.text).toContain('The parent session may have')
+ }
+ const inheritedResults = source.slice(0, boundary.seq + 1).filter(event => event.type === 'tool/result')
+ const expectedResults = [...inheritedResults, ...results].map(event => event.data.message)
+ const { agent: forked } = await ctx.agents.create({
+ sessionId: SessionId('paired-child-agent'), seed: child.snapshotEvents(),
+ inheritedEventCount: child.inheritedEventCount, meta: { isSeeded: true },
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
+ send(forked, 'continue this branch')
+ await waitForIdle(ctx, forked)
+ const request = adapter.requests[2]
+ if (request === undefined) throw new Error('child never reached the adapter')
+ expectPairedTools(request.messages)
+ expect(request.messages.filter(message => message.role === 'tool')).toEqual(expectedResults)
+ expect(expectedResults.map(message => message.source.callId)).toEqual([ToolCallId('call-first'), ToolCallId('call-second')])
+ expect(() => {
+ expectPairedTools(request.messages.filter(message => (
+ message.role !== 'tool' || message.source.callId !== ToolCallId('call-second')
+ )))
+ }).toThrow()
+ expect(executions).toEqual(['first', 'second'])
+ expect(parent.session.snapshotEvents()).toEqual(source)
+ expect(forked.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'child complete' }])
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
})
describe('discriminated SessionEvent narrows without casts', () => {
@@ -1144,12 +1297,10 @@ describe('tool result call identity', () => {
// And deriveMessages pairs the tool-result with the assistant tool-call:
// the derived tool-result block's toolCallId equals the original call.id.
const messages = agent.session.deriveMessages()
- const toolResultBlock = messages
- .flatMap(m => m.content)
- .find(b => b.type === 'tool-result')
- expect(toolResultBlock?.type).toBe('tool-result')
- if (toolResultBlock?.type === 'tool-result') {
- expect(toolResultBlock.toolCallId).toBe(ToolCallId('c1'))
+ const toolResultMessage = messages.find(m => m.role === 'tool')
+ expect(toolResultMessage?.role).toBe('tool')
+ if (toolResultMessage?.role === 'tool') {
+ expect(toolResultMessage.toolCallId).toBe(ToolCallId('c1'))
}
})
})
diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts
index e816a1d2241e..bead0e5bc6ff 100644
--- a/packages/core/agent-loop/tests/coverage-edges.spec.ts
+++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@mutantcat/cordis'
import LlmRuntime, { createUserMessage, ToolCallId, LlmError, StreamChunk, errorChain } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@mutantcat/dsh-session'
import type { SessionEvent } from '@mutantcat/dsh-session'
import SystemPrompt from '@mutantcat/dsh-system-prompt'
@@ -11,6 +12,12 @@ import AgentLoop from '@mutantcat/dsh-agent-loop'
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
function driverDone(agent: Agent): Promise {
return (agent as Agent & { done: Promise }).done
}
@@ -237,7 +244,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
await waitForIdle(ctx, agent)
const toolResult = agent.session.snapshotEvents().find(e => e.type === 'tool/result')
- expect(toolResult?.type === 'tool/result' && toolResult.data.message.content[0].isError).toBe(true)
+ expect(toolResult?.type === 'tool/result' && toolResult.data.message.isError).toBe(true)
expect(toolResult?.type === 'tool/result' && toolResult.data.error)
.toEqual({ name: 'HarnessError', code: 'BOOM' })
})
@@ -493,7 +500,7 @@ describe('driver bookkeeping edges', () => {
ctx.on('agent/turn-stopping', ({ agent: subject }) => {
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'do not enter the next step' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}))
})
diff --git a/packages/core/agent-loop/tests/fixtures/serial-created.mjs b/packages/core/agent-loop/tests/fixtures/serial-created.mjs
index 3ddc41b52b93..c4ed2df17c29 100644
--- a/packages/core/agent-loop/tests/fixtures/serial-created.mjs
+++ b/packages/core/agent-loop/tests/fixtures/serial-created.mjs
@@ -14,7 +14,7 @@ export function apply(ctx) {
id: randomUUID(),
role: 'user',
content: [{ type: 'text', text: 'Serial agent creation completed.' }],
- source: { kind: 'plugin', plugin: name },
+ source: { kind: `plugin:${name}` },
})
ready.add(agent)
})
diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts
index 15f2d4d6b1db..4b4f1c7cf7ee 100644
--- a/packages/core/agent-loop/tests/inbox.spec.ts
+++ b/packages/core/agent-loop/tests/inbox.spec.ts
@@ -34,6 +34,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent {
steer: () => {},
inject: () => {},
cancel() {},
+ interruptModelStep: () => false,
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
...overrides,
diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts
index 12aba31cf57a..70c50fd47380 100644
--- a/packages/core/agent-loop/tests/interception.spec.ts
+++ b/packages/core/agent-loop/tests/interception.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@mutantcat/cordis'
import LlmRuntime, { createUserMessage, ToolCallId } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, {
SessionId,
type SessionEvent,
@@ -19,6 +20,17 @@ import AgentLoop from '@mutantcat/dsh-agent-loop'
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'a': { kind: 'a' } & ContextFormed
+ 'accepted source': { kind: 'accepted source' } & ContextFormed
+ 'b': { kind: 'b' } & ContextFormed
+ 'native-guard': { kind: 'native-guard' } & ContextFormed
+ 'p': { kind: 'p' } & ContextFormed
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
/**
* The interception points introduced by the hooks taxonomy: `agent/pre-step`,
* `agent/created`, `agent/turn-stopping`, and the
@@ -132,7 +144,7 @@ describe('agent/pre-step', () => {
})
const input: UserMessage = createUserMessage({
content: [{ type: 'text', text: 'accepted text' }],
- source: { kind: 'plugin', plugin: 'accepted source' },
+ source: { kind: 'accepted source' },
})
const idle = waitForIdle(ctx, agent)
@@ -143,7 +155,9 @@ describe('agent/pre-step', () => {
if (block?.type === 'text') block.text = 'caller mutation'
}).toThrow(TypeError)
expect(() => {
- if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation'
+ if (input.source.kind === 'accepted source') {
+ (input.source as unknown as { plugin?: string }).plugin = 'caller mutation'
+ }
}).toThrow(TypeError)
decision.resolve({ kind: 'enter', messages: [input] })
await idle
@@ -152,7 +166,7 @@ describe('agent/pre-step', () => {
expect(observed[0]).not.toBe(input)
expect(observed[0]).toMatchObject({
content: [{ type: 'text', text: 'accepted text' }],
- source: { kind: 'plugin', plugin: 'accepted source' },
+ source: { kind: 'accepted source' },
})
const userMsg = events(agent).find(event => event.type === 'user/message')
expect(userMsg?.type === 'user/message' && userMsg.data).toEqual(input)
@@ -189,7 +203,7 @@ describe('agent/pre-step', () => {
kind: 'enter',
messages: [...messages, createUserMessage({
content: [{ type: 'text', text: 'extra ctx' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})],
}))
@@ -198,10 +212,10 @@ describe('agent/pre-step', () => {
const log = events(agent)
const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user')
- const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
+ const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind !== 'user')
expect(userMsg).toBeDefined()
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
- expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
+ expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'test' })
const sent = JSON.stringify(adapter.requests[0]!.messages)
expect(sent).toContain('extra ctx')
})
@@ -216,7 +230,7 @@ describe('agent/pre-step', () => {
ctx.on('agent/turn-stopping', ({ agent: subject }) => {
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'pending context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}))
})
ctx.on('agent/pre-step', async ({ step }, next) => {
@@ -280,7 +294,7 @@ describe('agent/pre-step', () => {
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'attached context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'pre-step steering' }], source: { kind: 'user' } }))
expect(events(agent).some(event => event.type === 'user/message')).toBe(false)
@@ -334,7 +348,7 @@ describe('agent/pre-step', () => {
await entered.promise
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'staged context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } }))
decision.resolve({ kind: 'reject' })
@@ -387,7 +401,7 @@ describe('agent/pre-step', () => {
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) {
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'earlier state change' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}))
subject.steer(createUserMessage({
content: [{ type: 'text', text: 'earlier steering' }],
@@ -439,7 +453,7 @@ describe('agent/pre-step', () => {
await entered.promise
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'independent context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}))
decision.resolve({ kind: 'reject' })
await idle
@@ -579,7 +593,7 @@ describe('agent/created', () => {
const ctx = await harness(adapter)
ctx.on('agent/created', ({ agent }) => {
- agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'test' } }))
})
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -589,8 +603,8 @@ describe('agent/created', () => {
// the injected context reached the model on the first (only) request
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
// and is recorded with the plugin source, never mislabeled as a user prompt
- const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
- expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
+ const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind !== 'user')
+ expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'test' })
})
it('a throwing creation listener rolls back the agent before its first turn', async () => {
@@ -632,7 +646,7 @@ describe('tool additionalContexts buffering across a step', () => {
kind: 'accept',
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
- source: { kind: 'plugin', plugin: 'p' },
+ source: { kind: 'p' },
})],
}))
@@ -641,7 +655,7 @@ describe('tool additionalContexts buffering across a step', () => {
// Event order in the log: both tool/results, THEN both injected contexts —
// never interleaved (which would break tool-call/result adjacency).
- const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
+ const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind !== 'user')
const seqs = events(agent)
const firstResult = seqs.findIndex(e => e.type === 'tool/result')
const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result')
@@ -663,10 +677,10 @@ describe('tool additionalContexts buffering across a step', () => {
name: 'composite', description: 'composite', parameters: {},
async execute(_args, exec) {
exec.deferContext(createUserMessage({
- content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' },
+ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'a' },
}))
exec.deferContext(createUserMessage({
- content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' },
+ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'b' },
}))
return [{ type: 'text', text: 'outer result' }]
},
@@ -678,12 +692,12 @@ describe('tool additionalContexts buffering across a step', () => {
const log = events(agent)
const resultIndex = log.findIndex(event => event.type === 'tool/result')
- const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
+ const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
expect(resultIndex).toBeGreaterThanOrEqual(0)
expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([
- { kind: 'plugin', plugin: 'a' },
- { kind: 'plugin', plugin: 'b' },
+ { kind: 'a' },
+ { kind: 'b' },
])
})
})
@@ -709,9 +723,9 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
expect(ran).toBe(false)
const result = events(agent).find(e => e.type === 'tool/result')
- expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
+ expect(result?.type === 'tool/result' && result.data.message.isError).toBe(true)
expect(result?.type === 'tool/result'
- && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
+ && result.data.message.content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
})
})
@@ -724,7 +738,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
apply(ctx: Context) {
// 1. SessionStart: seed a standing instruction.
ctx.on('agent/created', ({ agent, source }) => {
- agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'native-guard' } }))
})
// 2. PreStep: reject a forbidden prompt, annotate the rest.
ctx.on('agent/pre-step', async ({ messages }, next): Promise => {
@@ -745,7 +759,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const decision = await next()
if (decision.kind === 'accept') {
return { kind: 'accept', additionalContexts: [createUserMessage({
- content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' },
+ content: [{ type: 'text', text: 'audited' }], source: { kind: 'native-guard' },
})] }
}
return decision
@@ -768,13 +782,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const log = events(agent)
// session-start preamble injected
- expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
+ expect(log.some(e => e.type === 'user/message' && e.data.source.kind !== 'user'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
// prompt allowed → user-sourced user/message recorded
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true)
// tool ran (echo allowed) and post-execute attached "audited" context
- expect(log.some(e => e.type === 'tool/result' && !e.data.message.content[0].isError)).toBe(true)
- expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
+ expect(log.some(e => e.type === 'tool/result' && !e.data.message.isError)).toBe(true)
+ expect(log.some(e => e.type === 'user/message' && e.data.source.kind !== 'user'
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
// NO hook/* events — a native plugin needs none
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts
index 5a6b1f3e4cf9..24139cf01e18 100644
--- a/packages/core/agent-loop/tests/invariant.spec.ts
+++ b/packages/core/agent-loop/tests/invariant.spec.ts
@@ -4,6 +4,13 @@ import SessionStore, { SessionId } from '@mutantcat/dsh-session'
import InvariantRegistry from '@mutantcat/dsh-invariants'
import * as AgentLoopInvariant from '@mutantcat/dsh-agent-loop/invariant'
import { createUserMessage, markAgentLoopRequest, type GenerateOptions } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
+
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'x': { kind: 'x' } & ContextFormed
+ }
+}
async function setup(): Promise {
const ctx = new Context()
@@ -45,7 +52,7 @@ describe('request-reconstruction invariant', () => {
it('includes context appended inside the open step before dispatch', async () => {
const { ctx, session } = await requestSetup()
session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: '[step context]' }], source: { kind: 'plugin', plugin: 'x' },
+ content: [{ type: 'text', text: '[step context]' }], source: { kind: 'x' },
}), { surfaceOp: 'append' })
const options = loopRequest({
model: 'm',
diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts
index 6d85384e38a0..0d7709fd5895 100644
--- a/packages/core/agent-loop/tests/loop.spec.ts
+++ b/packages/core/agent-loop/tests/loop.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@mutantcat/cordis'
import LlmRuntime, { createUserMessage, ToolCallId, LlmError, ReasoningEffortId, StreamChunk, expandAssistantStream } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import type { GenerateOptions } from '@mutantcat/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@mutantcat/dsh-session'
import SystemPrompt from '@mutantcat/dsh-system-prompt'
@@ -11,6 +12,18 @@ import AgentLoop from '@mutantcat/dsh-agent-loop'
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'loop-test': { kind: 'loop-test' } & ContextFormed
+ 'max-tokens-test': { kind: 'max-tokens-test' } & ContextFormed
+ 'test': { kind: 'test' } & ContextFormed
+ 'test-compaction': { kind: 'test-compaction' } & ContextFormed
+ 'test-context': { kind: 'test-context' } & ContextFormed
+ 'watcher': { kind: 'watcher' } & ContextFormed
+ 'x': { kind: 'x' } & ContextFormed
+ }
+}
+
function driverDone(agent: Agent): Promise {
return (agent as Agent & { done: Promise }).done
}
@@ -496,12 +509,11 @@ describe('agent loop', () => {
// the second request's derived history contains the tool result
const secondMessages = adapter.requests[1]!.messages
- const toolResultMessage = secondMessages.find(m =>
- m.content.some(b => b.type === 'tool-result'))
+ const toolResultMessage = secondMessages.find(m => m.role === 'tool')
expect(toolResultMessage).toBeDefined()
- const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
- expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
- expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }])
+ const message = toolResultMessage!
+ expect(message).toMatchObject({ toolCallId: 'c1', isError: false })
+ expect(message.content).toEqual([{ type: 'text', text: 'echo: ping' }])
// session log records call + result
const types = agent.session.snapshotEvents().map(e => e.type)
@@ -683,8 +695,7 @@ describe('agent loop', () => {
const agent = await ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' })
const contextEvents = () => agent.session.snapshotEvents().flatMap(event =>
event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === '@mutantcat/dsh-system-prompt'
+ && event.data.source.kind === 'runtime-context'
? [event]
: [])
@@ -737,12 +748,11 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
const contextEvent = agent.session.snapshotEvents().find(event =>
event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === '@mutantcat/dsh-system-prompt')
+ && event.data.source.kind === 'runtime-context')
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
- source: { kind: 'plugin', plugin: 'test-compaction' },
+ source: { kind: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', startSeq: contextEvent.seq, endSeq: contextEvent.seq },
sourceEventSeqs: [contextEvent.seq],
@@ -752,14 +762,12 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
const runtimeContexts = agent.session.snapshotEvents().flatMap(event =>
event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === '@mutantcat/dsh-system-prompt'
+ && event.data.source.kind === 'runtime-context'
? [event]
: [])
expect(runtimeContexts).toHaveLength(2)
expect(adapter.requests[1]?.messages.some(message =>
- message.source.kind === 'plugin'
- && message.source.plugin === '@mutantcat/dsh-system-prompt')).toBe(true)
+ message.role === 'user' && message.source?.kind === 'runtime-context')).toBe(true)
})
it('clears compacted runtime context after the active set becomes empty', async () => {
@@ -772,12 +780,11 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
const contextEvent = agent.session.snapshotEvents().find(event =>
event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === '@mutantcat/dsh-system-prompt')
+ && event.data.source.kind === 'runtime-context')
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary retaining old mode: read-only' }],
- source: { kind: 'plugin', plugin: 'test-compaction' },
+ source: { kind: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', startSeq: contextEvent.seq, endSeq: contextEvent.seq },
sourceEventSeqs: [contextEvent.seq],
@@ -787,9 +794,7 @@ describe('agent loop', () => {
send(agent, 'after compaction')
await waitForIdle(ctx, agent)
const clearing = adapter.requests[1]?.messages.find(message =>
- message.role === 'user'
- && message.source.kind === 'plugin'
- && message.source.plugin === '@mutantcat/dsh-system-prompt')
+ message.role === 'user' && message.source?.kind === 'runtime-context')
expect(clearing?.content).toEqual([{
type: 'text',
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
@@ -802,11 +807,11 @@ describe('agent loop', () => {
const agent = await ctx.agentLoop.create(SessionId('a-runtime-context-unrelated-compaction'), { provider: 'mock', model: 'mock' })
const original = agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'old context' }],
- source: { kind: 'plugin', plugin: 'test-context' },
+ source: { kind: 'test-context' },
}), { surfaceOp: 'append' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
- source: { kind: 'plugin', plugin: 'test-compaction' },
+ source: { kind: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', startSeq: original.seq, endSeq: original.seq },
sourceEventSeqs: [original.seq],
@@ -815,9 +820,7 @@ describe('agent loop', () => {
send(agent, 'after compaction')
await waitForIdle(ctx, agent)
expect(adapter.requests[0]?.messages.some(message =>
- message.role === 'user'
- && message.source.kind === 'plugin'
- && message.source.plugin === '@mutantcat/dsh-system-prompt')).toBe(false)
+ message.role === 'user' && message.source?.kind === 'runtime-context')).toBe(false)
})
it('replaces a malformed retained runtime-context message with the current complete snapshot', async () => {
@@ -827,15 +830,14 @@ describe('agent loop', () => {
const agent = await ctx.agentLoop.create(SessionId('a-runtime-context-malformed'), { provider: 'mock', model: 'mock' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'broken' }, { type: 'text', text: 'snapshot' }],
- source: { kind: 'plugin', plugin: '@mutantcat/dsh-system-prompt' },
+ source: { kind: 'runtime-context' },
}), { surfaceOp: 'append' })
send(agent, 'repair context')
await waitForIdle(ctx, agent)
const runtimeContexts = agent.session.snapshotEvents().flatMap(event =>
event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === '@mutantcat/dsh-system-prompt'
+ && event.data.source.kind === 'runtime-context'
? [event]
: [])
expect(runtimeContexts).toHaveLength(2)
@@ -961,7 +963,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
- agent.inject(createUserMessage({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' } }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'watcher' } }))
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(0)
@@ -972,7 +974,7 @@ describe('agent loop', () => {
inserted: [{
role: 'user',
content: [{ type: 'text', text: 'file changed: a.ts' }],
- source: { kind: 'plugin', plugin: 'watcher' },
+ source: { kind: 'watcher' },
}],
},
})
@@ -990,13 +992,13 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' })
const text = 'Additional instructions from: pkg/AGENTS.md'
- agent.inject(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'agent-instructions' } }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'agent-instructions', form: 'instructions', changes: [] } }))
send(agent, 'go')
await waitForIdle(ctx, agent)
- const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
+ const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')
expect(contextEvent?.type === 'user/message' && contextEvent.data.source)
- .toEqual({ kind: 'plugin', plugin: 'agent-instructions' })
+ .toEqual({ kind: 'agent-instructions', form: 'instructions', changes: [] })
const requestText = JSON.stringify(adapter.requests[0]!.messages)
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
expect(requestText).not.toContain(' {
async execute() {
await Promise.resolve()
const first = { type: 'text' as const, text: 'mid-turn notice' }
- agent.inject(createUserMessage({ content: [first], source: { kind: 'plugin', plugin: 'x' } }))
+ agent.inject(createUserMessage({ content: [first], source: { kind: 'x' } }))
first.text = 'mutated after inject'
- agent.inject(createUserMessage({ content: [{ type: 'text', text: 'second notice' }], source: { kind: 'plugin', plugin: 'x' } }))
- visibleDuringTool = agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'second notice' }], source: { kind: 'x' } }))
+ visibleDuringTool = agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'x')
return [{ type: 'text', text: 'ok' }]
},
}))
@@ -1035,7 +1037,7 @@ describe('agent loop', () => {
const turnStarts = agent.session.snapshotEvents().filter(e => e.type === 'turn/start')
expect(turnStarts).toHaveLength(1)
const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result')!
- const contexts = agent.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
+ const contexts = agent.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind === 'x')
expect(contexts).toHaveLength(2)
expect(result.seq).toBeLessThan(contexts[0]!.seq)
expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : []))
@@ -1045,8 +1047,7 @@ describe('agent loop', () => {
])
const secondRequest = adapter.requests[1]!.messages
- const resultIndex = secondRequest.findIndex(message =>
- message.content.some(block => block.type === 'tool-result'))
+ const resultIndex = secondRequest.findIndex(message => message.role === 'tool')
const contextIndexes = secondRequest.flatMap((message, index) =>
message.content.some(block => block.type === 'text'
&& (block.text.includes('mid-turn notice') || block.text.includes('second notice')))
@@ -1070,7 +1071,7 @@ describe('agent loop', () => {
parameters: {},
async execute() {
expect(() => {
- agent.inject(createUserMessage({ content: [{ type: 'text', text: 'invalid' }], source: { kind: 'plugin', plugin: 'test', bigint: 1n } as never }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'invalid' }], source: { kind: 'test', bigint: 1n } as never }))
}).toThrow('agent context must be losslessly JSON-serializable')
return [{ type: 'text', text: 'rejected invalid context' }]
},
@@ -1079,7 +1080,7 @@ describe('agent loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
- expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
+ expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'test')).toBe(false)
})
it('agent/turn-stopping can steer another step (/loop pattern)', async () => {
@@ -1095,7 +1096,7 @@ describe('agent loop', () => {
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/turn-stopping', ({ agent: subject }) => {
if (steps < 3) {
- subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } }))
+ subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'loop-test' } }))
}
})
@@ -1323,7 +1324,7 @@ describe('agent loop', () => {
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/turn-stopping', ({ agent: subject }) => {
if (steps < 2) {
- subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } }))
+ subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'max-tokens-test' } }))
}
})
@@ -1352,7 +1353,7 @@ describe('agent loop', () => {
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'continue after truncation' }],
- source: { kind: 'plugin', plugin: 'max-tokens-test' },
+ source: { kind: 'max-tokens-test' },
},
])
// A max-token step is sticky: the later completed step must not
@@ -1640,7 +1641,7 @@ describe('agent loop', () => {
const idle = waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'user message' }], source: { kind: 'user' } }))
await Promise.resolve()
- agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } }))
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'test' } }))
await idle
const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start')
@@ -1650,7 +1651,7 @@ describe('agent loop', () => {
expect(turns).toHaveLength(2)
expect(sources).toEqual([
{ kind: 'user' },
- { kind: 'plugin', plugin: 'test' },
+ { kind: 'test' },
])
})
diff --git a/packages/core/agent-loop/tests/model-step-interrupt.spec.ts b/packages/core/agent-loop/tests/model-step-interrupt.spec.ts
new file mode 100644
index 000000000000..371c1071235a
--- /dev/null
+++ b/packages/core/agent-loop/tests/model-step-interrupt.spec.ts
@@ -0,0 +1,79 @@
+/**
+ * `Agent.interruptModelStep()` cuts only the in-flight provider wait: the turn
+ * keeps going and admits the next step, which is how a mid-turn model switch
+ * applies without cancelling the turn.
+ * @module dsh-agent-loop/tests/model-step-interrupt
+ */
+
+import { describe, expect, it } from 'vitest'
+import { createUserMessage } from '@mutantcat/dsh-llm'
+import { Context } from '@mutantcat/cordis'
+import LlmRuntime from '@mutantcat/dsh-llm'
+import SessionStore, { SessionId } from '@mutantcat/dsh-session'
+import SystemPrompt from '@mutantcat/dsh-system-prompt'
+import ToolRuntime from '@mutantcat/dsh-tools'
+import AgentRegistry, { type Agent } from '@mutantcat/dsh-agent'
+import AgentLoop from '@mutantcat/dsh-agent-loop'
+import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
+import { MockAdapter, textResponse } from './mock-adapter.ts'
+
+async function harness(adapter: MockAdapter): Promise {
+ const ctx = new Context()
+ await ctx.plugin(LlmRuntime)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRuntime)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ ctx.llm.registerAdapter(['mock'], adapter)
+ return ctx
+}
+
+function send(agent: Agent, text: string): void {
+ agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
+}
+
+describe('Agent.interruptModelStep()', () => {
+ it('reports nothing to cut while the agent is idle', async () => {
+ const ctx = await harness(new MockAdapter([textResponse('unused')]))
+ const agent = await ctx.agentLoop.create(SessionId('idle-interrupt'), { provider: 'mock', model: 'mock' })
+
+ expect(agent.interruptModelStep()).toBe(false)
+ })
+
+ it('settles the delivered prefix and lets the turn admit the next step', async () => {
+ const adapter = new MockAdapter(['hang', textResponse('second reply')])
+ const ctx = await harness(adapter)
+ const agent = await ctx.agentLoop.create(SessionId('step-interrupt'), { provider: 'mock', model: 'mock' })
+
+ send(agent, 'first prompt')
+ while (adapter.requests.length === 0) await new Promise(resolve => setTimeout(resolve, 5))
+ await new Promise(resolve => setTimeout(resolve, 30))
+
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'switch model' }], source: { kind: 'user' } }))
+ expect(agent.interruptModelStep()).toBe(true)
+ await agent.whenIdle()
+
+ expect(adapter.requests).toHaveLength(2)
+ const messages = agent.session.snapshotEvents().filter(event => event.type === 'assistant/message')
+ expect(messages).toHaveLength(2)
+ expect(messages[0]?.data).toMatchObject({ interrupted: true })
+ expect(messages[1]?.data).toMatchObject({ message: { content: [{ type: 'text', text: 'second reply' }] } })
+ const end = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')
+ expect(end?.data.reason).toEqual({ kind: 'completed' })
+ })
+
+ it('ignores a second interrupt once the provider wait is already cut', async () => {
+ const adapter = new MockAdapter(['hang', textResponse('after')])
+ const ctx = await harness(adapter)
+ const agent = await ctx.agentLoop.create(SessionId('double-interrupt'), { provider: 'mock', model: 'mock' })
+
+ send(agent, 'prompt')
+ while (adapter.requests.length === 0) await new Promise(resolve => setTimeout(resolve, 5))
+ expect(agent.interruptModelStep()).toBe(true)
+ expect(agent.interruptModelStep()).toBe(false)
+ agent.cancel({ kind: 'user' })
+ await agent.whenIdle()
+ })
+})
diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts
index 64e57671aed5..4b3bfd192e45 100644
--- a/packages/core/agent-loop/tests/properties.spec.ts
+++ b/packages/core/agent-loop/tests/properties.spec.ts
@@ -74,7 +74,7 @@ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: ()
function userMessageTexts(agent: Agent): string[] {
return agent.session.snapshotEvents()
.filter(e => e.type === 'user/message')
- .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
+ .map(e => e.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join(''))
}
function turnNumbers(agent: Agent): number[] {
diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts
index 1205b3eee95a..0890d425297a 100644
--- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts
+++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts
@@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@mutantcat/cordis'
import LlmRuntime, { createUserMessage, LlmError, ReasoningEffortId } from '@mutantcat/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@mutantcat/dsh-session'
import SystemPrompt from '@mutantcat/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@mutantcat/dsh-tools'
@@ -18,6 +19,14 @@ import AgentLoop from '@mutantcat/dsh-agent-loop'
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'outer-wrapper': { kind: 'outer-wrapper' } & ContextFormed
+ 'test': { kind: 'test' } & ContextFormed
+ 'test-compact': { kind: 'test-compact' } & ContextFormed
+ }
+}
+
async function harness(adapter: MockAdapter, persona = 'stable base') {
return harnessRoutes([['mock', adapter]], persona)
}
@@ -177,7 +186,7 @@ describe('request stability across the loop', () => {
if (decision.kind === 'reject') return decision
const appended = createUserMessage({
content: [{ type: 'text', text: 'appended reference context' }],
- source: { kind: 'plugin', plugin: 'outer-wrapper' },
+ source: { kind: 'outer-wrapper' },
})
return { ...decision, messages: [...decision.messages, appended] }
}, { prepend: true })
@@ -508,7 +517,7 @@ describe('request stability across the loop', () => {
const nodes = agent.session.surface.nodes
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '[summary of turn 1]' }],
- source: { kind: 'plugin', plugin: 'test-compact' },
+ source: { kind: 'test-compact' },
}), {
surfaceOp: { op: 'replace', startSeq: nodes[1]!, endSeq: nodes[2]! },
sourceEventSeqs: [nodes[1]!, nodes[2]!],
@@ -544,7 +553,7 @@ describe('request stability across the loop', () => {
if (first === undefined) throw new Error('request has no surface message to compact')
subject.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '[summary for retry]' }],
- source: { kind: 'plugin', plugin: 'test-compact' },
+ source: { kind: 'test-compact' },
}), {
surfaceOp: { op: 'replace', startSeq: first, endSeq: first },
sourceEventSeqs: [first],
@@ -694,7 +703,7 @@ describe('request stability across the loop', () => {
const nodes = agent.session.surface.nodes
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '[summary of turn 1]' }],
- source: { kind: 'plugin', plugin: 'test-compact' },
+ source: { kind: 'test-compact' },
}), {
surfaceOp: { op: 'replace', startSeq: nodes[1]!, endSeq: nodes[2]! },
sourceEventSeqs: [nodes[1]!, nodes[2]!],
@@ -742,7 +751,7 @@ describe('request stability across the loop', () => {
ctx.on('agent/request', async (_payload, next) => {
if (!injected) {
injected = true
- agent.inject(createUserMessage({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } }))
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'test' } }))
}
return next()
})
@@ -752,7 +761,7 @@ describe('request stability across the loop', () => {
const first = adapter.requests[0]!
// The inject landed in the log after the boundary: not in THIS request…
expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false)
- expect(agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true)
+ expect(agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(true)
send(agent, 'second')
await waitForIdle(ctx, agent)
@@ -771,7 +780,7 @@ describe('request stability across the loop', () => {
// request content in place. The freeze turns it into a loud error.
options.messages.push(createUserMessage({
content: [{ type: 'text', text: 'sneaky' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}))
return next()
})
diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts
index f590408ecc95..2d6238de5837 100644
--- a/packages/core/agent-loop/tests/resume.spec.ts
+++ b/packages/core/agent-loop/tests/resume.spec.ts
@@ -5,6 +5,7 @@ import { appendFile, mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmRuntime from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { SessionLogOffset, SessionSeq, Session, SessionId, TOOL_OUTCOME_UNKNOWN } from '@mutantcat/dsh-session'
import type { SessionEvent } from '@mutantcat/dsh-session'
import SystemPrompt from '@mutantcat/dsh-system-prompt'
@@ -17,6 +18,12 @@ import AgentLoop from '@mutantcat/dsh-agent-loop'
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import { MockAdapter, textResponse } from './mock-adapter.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'tool-bash': { kind: 'tool-bash' } & ContextFormed
+ }
+}
+
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
@@ -943,7 +950,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' }, agentOptions: { provider: 'mock', model: 'mock' } })).agent
a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
await waitForIdle(ctx1, a1)
- a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background job 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } }))
+ a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background job 42 finished' }], source: { kind: 'tool-bash' } }))
await a1.whenIdle()
await ctx1.sessions.flush(a1.session)
// Simulate a wedged first lifecycle: a graceful dispose would durably
diff --git a/packages/core/agent-loop/tests/runtime-context.spec.ts b/packages/core/agent-loop/tests/runtime-context.spec.ts
index 78728a312f79..7cd6831eae5e 100644
--- a/packages/core/agent-loop/tests/runtime-context.spec.ts
+++ b/packages/core/agent-loop/tests/runtime-context.spec.ts
@@ -1,15 +1,22 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@mutantcat/cordis'
import { createUserMessage } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { SessionId } from '@mutantcat/dsh-session'
import { RuntimeContextProjection } from '../src/runtime-context.ts'
-const SOURCE = '@mutantcat/dsh-system-prompt'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test-compaction': { kind: 'test-compaction' } & ContextFormed
+ }
+}
+
+const SOURCE = 'runtime-context'
function contextMessage(text: string) {
return createUserMessage({
content: [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: SOURCE },
+ source: { kind: SOURCE },
})
}
@@ -22,7 +29,7 @@ describe('RuntimeContextProjection', () => {
const shadowed = session.append('user/message', contextMessage('shadowed'), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }],
- source: { kind: 'plugin', plugin: 'test-compaction' },
+ source: { kind: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', startSeq: shadowed.seq, endSeq: shadowed.seq },
sourceEventSeqs: [shadowed.seq],
@@ -32,8 +39,7 @@ describe('RuntimeContextProjection', () => {
expect(session.surface.nodes).toContain(retained.seq)
expect(projection.project('retained', [])).toBeUndefined()
expect(projection.project('next', [{ name: 'sandbox:policy', text: 'policy' }])?.source).toEqual({
- kind: 'plugin',
- plugin: SOURCE,
+ kind: SOURCE,
form: 'snapshot',
sections: [{ name: 'sandbox:policy', text: 'policy' }],
})
diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts
index 76ccee28dfc9..14ee98b363cb 100644
--- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts
+++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts
@@ -10,6 +10,12 @@ import SystemPrompt from '@mutantcat/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@mutantcat/dsh-tools'
import { MockAdapter, textResponse } from './mock-adapter.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test-compaction': { kind: 'test-compaction' } & import('@mutantcat/dsh-llm').ContextFormed
+ }
+}
+
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
@@ -151,7 +157,7 @@ describe('prepared-route prompt admission', () => {
const start = retainOlder ? latest : nodes[1]!
const replaced = nodes.slice(nodes.indexOf(start), nodes.indexOf(latest) + 1)
h.agent.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'plugin', plugin: 'test-compaction' },
+ content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'test-compaction' },
}), { surfaceOp: { op: 'replace', startSeq: start, endSeq: latest }, sourceEventSeqs: replaced })
// A retry must retain the assembly accepted for this step, not pick up new sections.
h.setPrompt('not admitted until next step')
@@ -215,11 +221,11 @@ describe('prepared-route prompt admission', () => {
[{ type: 'text', text: 'second' }], notice,
])
const notices = h.agent.session.snapshotEvents().filter(event => event.type === 'user/message'
- && event.data.source.kind === 'plugin' && event.data.source.plugin === 'model-selection')
+ && event.data.source.kind === 'model-selection')
expect(notices).toHaveLength(1)
expect(notices[0]!.data).toMatchObject({
content: notice,
- source: { kind: 'plugin', plugin: 'model-selection', form: 'notice', summary: 'plain/model → capable/model' },
+ source: { kind: 'model-selection', form: 'notice', summary: 'plain/model → capable/model' },
})
})
@@ -236,7 +242,7 @@ describe('prepared-route prompt admission', () => {
if (agent === resumed && replace) {
const seq = agent.session.surface.nodes.find(seq => agent.session.eventAt(seq)?.type === 'user/message')!
agent.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'plugin', plugin: 'test-compaction' },
+ content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'test-compaction' },
}), { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] })
}
return next()
diff --git a/packages/core/agent-loop/tests/system-prompt-projection.spec.ts b/packages/core/agent-loop/tests/system-prompt-projection.spec.ts
index 66954e00e5ac..c35508bbf69b 100644
--- a/packages/core/agent-loop/tests/system-prompt-projection.spec.ts
+++ b/packages/core/agent-loop/tests/system-prompt-projection.spec.ts
@@ -6,7 +6,12 @@ import type { Session, SurfaceIntent } from '@mutantcat/dsh-session'
import { SystemPromptProjection } from '../src/runtime-context.ts'
import type { SystemPromptCommit, SystemPromptDecisionInput } from '../src/runtime-context.ts'
-const SOURCE = '@mutantcat/dsh-system-prompt'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test-compaction': { kind: 'test-compaction' } & import('@mutantcat/dsh-llm').ContextFormed
+ }
+}
+
const REPLACING: SystemPromptDecisionInput = { inHistory: false, startsSeries: false }
const CONTINUING: SystemPromptDecisionInput = { inHistory: true, startsSeries: false }
const NEW_SERIES: SystemPromptDecisionInput = { inHistory: true, startsSeries: true }
@@ -39,13 +44,13 @@ describe('SystemPromptProjection', () => {
const ctx = await sessionStore()
try {
const session = ctx.sessions.create(SessionId('system-prompt-multiblock'))
- const base = createSystemMessage('old', SOURCE)
+ const base = createSystemMessage('old')
session.append('system/message', { turn: 1, step: 1, message: {
...base, content: [{ type: 'text', text: 'old ' }, { type: 'text', text: 'instructions' }],
} }, { surfaceOp: 'append' })
appendUser(session, 'hello')
session.append('system/message', { turn: 1, step: 1, message: {
- ...createSystemMessage('tail', SOURCE), content: [{ type: 'reasoning', text: 'retained content' }],
+ ...createSystemMessage('tail'), content: [{ type: 'reasoning', text: 'retained content' }],
} }, { surfaceOp: 'append' })
const projection = new SystemPromptProjection(session)
for (const update of projection.project('', CONTINUING)) commit(session, 2, update)
@@ -64,7 +69,7 @@ describe('SystemPromptProjection', () => {
const first = projection.project('v1', REPLACING)[0]
expect(first?.intent).toEqual({ surfaceOp: 'append' })
expect(first?.message.role).toBe('system')
- expect(first?.message.source).toEqual({ kind: 'plugin', plugin: SOURCE })
+ expect(first?.message.source).toEqual({ kind: 'system-prompt' })
const head = commit(session, 1, first)
appendUser(session, 'hello')
@@ -114,16 +119,16 @@ describe('SystemPromptProjection', () => {
it('reads the surviving system node from the log, including one restored as "no prompt"', async () => {
const ctx = await sessionStore()
const session = ctx.sessions.create(SessionId('system-prompt-replay'))
- const stale = session.append('system/message', { turn: 1, step: 1, message: createSystemMessage('stale', SOURCE) }, { surfaceOp: 'append' })
+ const stale = session.append('system/message', { turn: 1, step: 1, message: createSystemMessage('stale') }, { surfaceOp: 'append' })
appendUser(session, 'hello')
- const current = session.append('system/message', { turn: 2, step: 1, message: createSystemMessage('current', SOURCE) }, replaceOf(stale.seq))
+ const current = session.append('system/message', { turn: 2, step: 1, message: createSystemMessage('current') }, replaceOf(stale.seq))
const projection = new SystemPromptProjection(session)
expect(projection.project('current', REPLACING)[0]).toBeUndefined()
expect(projection.project('next', REPLACING)[0]?.intent).toEqual(replaceOf(current.seq))
const emptySession = ctx.sessions.create(SessionId('system-prompt-empty-replay'))
- const empty = emptySession.append('system/message', { turn: 1, step: 1, message: createSystemMessage('', SOURCE) }, { surfaceOp: 'append' })
+ const empty = emptySession.append('system/message', { turn: 1, step: 1, message: createSystemMessage('') }, { surfaceOp: 'append' })
appendUser(emptySession, 'hello')
const emptyProjection = new SystemPromptProjection(emptySession)
expect(emptyProjection.project('', REPLACING)[0]).toBeUndefined()
@@ -143,7 +148,7 @@ describe('SystemPromptProjection', () => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }],
- source: { kind: 'plugin', plugin: 'test-compaction' },
+ source: { kind: 'test-compaction' },
}), replaceOf(node.seq))
expect(projection.project('late prompt', REPLACING)[0]?.intent).toEqual({ surfaceOp: 'append' })
})
diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts
index 4ac3fdda3fd9..9bf7ac2d0a6d 100644
--- a/packages/core/agent-loop/tests/tool-calls.spec.ts
+++ b/packages/core/agent-loop/tests/tool-calls.spec.ts
@@ -9,6 +9,7 @@ import { createUserMessage, ToolCallId, StreamChunk } from '@mutantcat/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@mutantcat/dsh-session'
import SystemPrompt from '@mutantcat/dsh-system-prompt'
import LlmRuntime from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER, type PostToolDecision, type PreToolDecision } from '@mutantcat/dsh-tools'
import AgentRegistry, { type Agent } from '@mutantcat/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@mutantcat/dsh-agent-loop'
@@ -17,6 +18,12 @@ import { MockAdapter, textResponse } from './mock-adapter.ts'
import { PtcRuntime } from '@mutantcat/dsh-ptc-runtime'
import type { PtcRunRequest, PtcRunResult } from '@mutantcat/dsh-ptc-runtime'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'p': { kind: 'p' } & ContextFormed
+ }
+}
+
async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
@@ -258,7 +265,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
await waitForIdle(ctx, agent)
const messages = agent.session.deriveMessages()
- const toolResults = messages.flatMap(m => m.content.filter(b => b.type === 'tool-result'))
+ const toolResults = messages.filter(m => m.role === 'tool')
expect(toolResults.map(b => b.toolCallId)).toEqual([ToolCallId('c1'), ToolCallId('c2')])
})
})
@@ -406,7 +413,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
ctx.tools.register(gated.tool)
ctx.on('tools/post-execute', async (exec, _result): Promise =>
({ kind: 'accept', additionalContexts: [createUserMessage({
- content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' },
+ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'p' },
})] }))
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -416,11 +423,11 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
await waitForIdle(ctx, agent)
const log = events(agent)
- const contextTexts = log.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
+ const contextTexts = log.filter(e => e.type === 'user/message' && e.data.source.kind !== 'user')
.map(e => ((e.data as { content: { text: string }[] }).content[0]!).text)
expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2'])
const lastResult = log.findLastIndex(e => e.type === 'tool/result')
- const firstContext = log.findIndex(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
+ const firstContext = log.findIndex(e => e.type === 'user/message' && e.data.source.kind !== 'user')
expect(lastResult).toBeLessThan(firstContext)
})
@@ -463,11 +470,11 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
expect(post).toEqual(['c1', 'c2'])
const results = events(agent).filter(e => e.type === 'tool/result')
expect(results.map(e => e.data.message.source.callId)).toEqual([ToolCallId('c1'), ToolCallId('c2'), ToolCallId('c3')])
- expect((results[1]!.data.message.content[0].content[0] as { text: string }).text).toContain('blocked by policy')
+ expect((results[1]!.data.message.content[0] as { text: string }).text).toContain('blocked by policy')
expect(results[1]!.data.error).toEqual({
name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED', reason: 'exact scope was not authorized',
})
- expect((results[2]!.data.message.content[0].content[0] as { text: string }).text).toContain('pre exploded')
+ expect((results[2]!.data.message.content[0] as { text: string }).text).toContain('pre exploded')
})
})
@@ -495,7 +502,7 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([ToolCallId('c1'), ToolCallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({
callId: e.data.message.source.callId,
- isError: e.data.message.content[0].isError,
+ isError: e.data.message.isError,
error: e.data.error,
}))).toEqual([
{ callId: ToolCallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
@@ -527,7 +534,7 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([ToolCallId('c1'), ToolCallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({
callId: e.data.message.source.callId,
- isError: e.data.message.content[0].isError,
+ isError: e.data.message.isError,
error: e.data.error,
}))).toEqual([
{ callId: ToolCallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
@@ -546,7 +553,7 @@ describe('tool-call scheduler: abort handling', () => {
ctx.on('tools/post-execute', async (exec, _result, next): Promise => ({
...await next(),
additionalContexts: [createUserMessage({
- content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' },
+ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'p' },
})],
}))
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -565,7 +572,7 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([ToolCallId('c1'), ToolCallId('c2'), ToolCallId('c3'), ToolCallId('c4')])
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({
callId: e.data.message.source.callId,
- isError: e.data.message.content[0].isError,
+ isError: e.data.message.isError,
error: e.data.error,
})))
.toEqual([
@@ -581,7 +588,7 @@ describe('tool-call scheduler: abort handling', () => {
},
])
const settled = events(agent).filter(e => e.type === 'tool/result'
- || (e.type === 'user/message' && e.data.source.kind === 'plugin'))
+ || (e.type === 'user/message' && e.data.source.kind !== 'user'))
expect(settled.map(e => e.type))
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result'])
expect(agent.inbox.nextStep.map(message => message.content[0]))
@@ -596,7 +603,7 @@ describe('tool-call scheduler: abort handling', () => {
expect(events(agent).flatMap(e =>
e.type === 'user/message'
- && e.data.source.kind === 'plugin'
+ && e.data.source.kind !== 'user'
&& e.data.content[0]?.type === 'text'
? [e.data.content[0].text]
: []))
@@ -638,7 +645,8 @@ describe('tool-call scheduler: abort handling', () => {
.toMatchObject({
message: {
source: { kind: 'tool', callId: ToolCallId('c3') },
- content: [{ isError: true }],
+ content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
+ isError: true,
},
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json
index 1adc3d6648d4..f2f423bd29ba 100644
--- a/packages/core/agent-tool-presentation/package.json
+++ b/packages/core/agent-tool-presentation/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-agent-tool-presentation",
"description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json
index 89cf3c617d7a..580e33d96691 100644
--- a/packages/core/agent/package.json
+++ b/packages/core/agent/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-agent",
"description": "Agent interface, registry, initiator scope, and event vocabulary for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/core/agent/src/consumed-work.ts b/packages/core/agent/src/consumed-work.ts
index c5369d12bb01..d485608c8e8e 100644
--- a/packages/core/agent/src/consumed-work.ts
+++ b/packages/core/agent/src/consumed-work.ts
@@ -48,10 +48,11 @@ function accountsForClaim(reason: TurnEndReason): boolean {
case 'interrupted':
case 'error':
return true
- /* v8 ignore next 4 -- unreachable: the one unnamed built-in, `max-tokens`, requires a step,
- * so its turn short-circuits as stepped before this call, and `TurnEndReasonMap` is
- * merge-extensible, so a backend-added variant cannot be listed; an unnameable ending over
- * consumed input must not read as success. */
+ /* v8 ignore next 5 -- unreachable: `max-tokens` requires a step and short-circuits
+ * before this call; `forked` exists only in constructor seed history, while production
+ * callers fold an operation-owned suffix. `TurnEndReasonMap` is merge-extensible, so a
+ * backend-added variant cannot be listed; an unnameable ending over consumed input must
+ * not read as success. */
default:
return true
}
diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts
index 5fd88bf506ec..96cb35d61421 100644
--- a/packages/core/agent/src/index.ts
+++ b/packages/core/agent/src/index.ts
@@ -86,11 +86,10 @@ export interface CreateAgentOptions {
/** Exact fork-inherited prefix length when the session metadata sets `isSeeded`. */
readonly inheritedEventCount?: SessionLogOffset
/**
- * Initial replay/fork history. A fork supplies a balanced completed-turn
- * prefix of the parent's log. The complete seed must be contiguous from seq
- * 0, carry only lossless-JSON data, and contain no open turn/step or dangling
- * tool call. The factory passes it to the session's durable
- * validator/snapshot boundary before publication.
+ * Initial replay/fork history, contiguous from seq 0 with lossless-JSON data.
+ * A fork supplies an exact parent prefix, its inherited marker, and closers
+ * for the open tail. Previously closed steps and turns remain unchanged.
+ * The factory validates and snapshots the seed before publication.
*/
readonly seed?: readonly SessionEvent[]
/** Per-agent options (model, …). */
diff --git a/packages/core/agent/src/model-selection.ts b/packages/core/agent/src/model-selection.ts
index 7403d9b877b8..815ca2b85716 100644
--- a/packages/core/agent/src/model-selection.ts
+++ b/packages/core/agent/src/model-selection.ts
@@ -11,6 +11,12 @@ import {
type ReasoningEffortId,
} from '@mutantcat/dsh-llm'
import type { PreStepDecision } from './runtime-types.ts'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'model-selection': { kind: 'model-selection' } & ContextFormed
+ }
+}
/** Complete provider, model, and optional reasoning effort selected for one live Agent. */
export interface ModelSelection {
@@ -47,9 +53,8 @@ function modelSwitchNotice(previous: ModelSelection, selected: ModelSelection) {
text: `[model changed: assistant turns above this point were generated by ${from}; the session continues with ${to}]`,
}],
source: {
- kind: 'plugin' as const,
- plugin: 'model-selection',
- form: 'notice' as const,
+ kind: 'model-selection',
+ form: 'notice',
summary: boundContextSummary(`${from} → ${to}`),
},
})
diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts
index 92a44ebf8160..098704926b99 100644
--- a/packages/core/agent/src/runtime-types.ts
+++ b/packages/core/agent/src/runtime-types.ts
@@ -191,6 +191,16 @@ declare module './types.ts' {
*/
cancel(cause: AgentCancelCause, options?: CancelOptions): void
+ /**
+ * Cut the in-flight model step short without ending its turn. The delivered
+ * prefix is settled as an interrupted assistant message and the loop admits
+ * the next step, whose request assembles from whatever selection is
+ * installed by then — one way a mid-turn model switch applies immediately.
+ * @returns true when a model step was waiting on its provider; false when
+ * none is in flight (idle, maintenance, or a step running tools).
+ */
+ interruptModelStep(): boolean
+
/**
* Resolve after the current whole-agent activity reaches quiescence. This
* follows replacement work started before the observed driver retires,
diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts
index 35d7ccb83881..a78736f319da 100644
--- a/packages/core/agent/tests/agent.spec.ts
+++ b/packages/core/agent/tests/agent.spec.ts
@@ -31,6 +31,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
+ interruptModelStep: () => false,
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
...overrides,
diff --git a/packages/core/agent/tests/model-selection.spec.ts b/packages/core/agent/tests/model-selection.spec.ts
index 58995c595de4..89d9b30c3a6b 100644
--- a/packages/core/agent/tests/model-selection.spec.ts
+++ b/packages/core/agent/tests/model-selection.spec.ts
@@ -32,7 +32,7 @@ function expectedNotice(from: string, to: string) {
type: 'text',
text: `[model changed: assistant turns above this point were generated by ${from}; the session continues with ${to}]`,
}],
- source: { kind: 'plugin', plugin: 'model-selection', form: 'notice', summary: `${from} → ${to}` },
+ source: { kind: 'model-selection', form: 'notice', summary: `${from} → ${to}` },
}
}
diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json
index 7de2166da390..a296b1c80480 100644
--- a/packages/core/scope/package.json
+++ b/packages/core/scope/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-scope",
"description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml
index 1bf878dcc6e2..646984896dac 100644
--- a/packages/core/session/README.i18n.yaml
+++ b/packages/core/session/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
-README.md: b3e6bfee470ca4edb20cb6fdfa60cd43d9aa2788
-README.zh.md: 9ab4ed4d08da3a4237681e83107b7278ec75983e
+README.md: 581d80c86f28c5d4000d6390816456f185bba026
+README.zh.md: 99ad04e06d1029e854cbfd62fe6ce5480a6e682c
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index b3e6bfee470c..581d80c86f28 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -47,11 +47,13 @@ session.append('user/message', { role: 'user', content: [{ type: 'text', text: '
session.deriveMessages() // the derived model history
```
-Surface events (`system/message`, `user/message`, `assistant/message`, `tool/result`) require `surfaceOp` in both typed events and append input. A replacement uses exactly `{ op: 'replace', startSeq, endSeq }`, with inclusive `SessionSeq` endpoints in current surface order. An Assistant message embeds its exact compact provider stream and forbids `sourceEventSeqs`. Known log-only events forbid both metadata fields and never produce a message.
+Surface events (`system/message`, `developer/message`, `user/message`, `assistant/message`, `tool/result`) require `surfaceOp` in both typed events and append input. A replacement uses exactly `{ op: 'replace', startSeq, endSeq }`, with inclusive `SessionSeq` endpoints in current surface order. An Assistant message embeds its exact compact provider stream and forbids `sourceEventSeqs`. Known log-only events forbid both metadata fields and never produce a message.
+
+`developer/message` stores tool-addition names and a `headerSeq` reference to an earlier `request/header`. The reference is required exactly when additions are present, and each name must identify exactly one complete schema in that header. Removals name the tool without a header reference. Historical headers remain available across restart, fork, and surface replacement; the current registry and latest header do not select an earlier addition's definition. `sourceEventSeqs` continues to describe derivation and replaced nodes.
Plugins declare content-changing events with `@messageProjection` and register a pure definition through `ctx.sessions.registerMessageProjection()`. Session calls the definition before accepting an event and caches its immutable message updates. Missing definitions reject append and restore; unloading a used definition also blocks cached reads. Detached constructors and `foldSurface(events, projections)` require explicit definitions. Reconstructors pass the fold's `projectedMessages` to `deriveEventMessage()`; live instance methods apply the same projections automatically. [Plugin-owned message projections](../../../.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md) defines ownership and offline assembly.
-Append, seed/restore, and event adoption/snapshot reject any `header.system` and exactly empty optional request-header fields (`tools: []`, `adapterDefaults: {}`) instead of normalizing input. Tool-result `data.error` is allowed only when `message.content[0].isError === true`; failure identity remains optional. Rejected appends do not change the log, derived state, or event feed. Adoption validates event-local metadata but not referenced history or replacement membership.
+Append, seed/restore, and event adoption/snapshot reject any `header.system` and exactly empty optional request-header fields (`tools: []`, `adapterDefaults: {}`) instead of normalizing input. Tool-result `data.error` is allowed only when the first-class message has `isError === true`; failure identity remains optional. Rejected appends do not change the log, derived state, or event feed. Adoption validates event-local metadata but not referenced history or replacement membership.
`system/message` holds the rendered system prompt: the first one is surface node 0, the prepared call capability governs admission, with a non-empty rendering consolidated at the first system node on an incapable route or appended after cached history inside a continuing `in-history` series; empty system nodes project to no message, so clearing the prompt requires logged empty replacements of all active system nodes, not just the latest; the surface fold rejects a replacement covering node 0 while it is a `system/message` unless the replacing event is itself a `system/message` over exactly that node, while later system nodes carry no protection and a compaction range may shadow them ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)).
@@ -63,7 +65,7 @@ Session log positions use two numeric types. `SessionSeq` identifies an existing
### Fork a session
-`ctx.sessions.fork(source, boundary?, childSessionId?)` selects source events through an inclusive `boundary` seq (default: the current last event), requires the prefix to end outside an open turn, and creates a live child session with lineage metadata. A tool-time delegation that must branch mid-turn clips to a completed prefix instead.
+`ctx.sessions.fork(source, boundary?, childSessionId?)` copies an exact inclusive event prefix (default: the last event) from a live source. `buildForkSeed` in `dsh-session/fork` places an inherited marker after the copied events, adds missing error tool results only for the open step, and closes that step and turn with a `forked` reason. Closed steps and turns remain unchanged, including historical missing results. The marker and closers belong to the child; `inheritedEventCount` counts only the copied prefix.
The logical `SessionHeader.isSeeded` field reports whether fork history exists without exposing a positional integer. `Session.inheritedEventCount` retains the exact checked `SessionLogOffset`; `ownEvents()` returns events at and after that cut, and `isOwnSeq(seq)` accepts only an existing child-owned position. A low-level seeded constructor must supply an explicit `seed` and `inheritedEventCount` because the constructor seed can contain child-owned setup events after the inherited prefix.
@@ -107,7 +109,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads,
### Derived history
-`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; an empty-content system node projects to no message. Plugin-owned projections change derived content without mutating recorded messages. Replacements and projection decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only.
+`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The surface event types (`system/message`, `developer/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; empty-content system and developer nodes project to no message. Plugin-owned projections change derived content without mutating recorded messages. Replacements and projection decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only.
### The request header
@@ -137,7 +139,7 @@ The package-level contract is enough for most consumers; read these when you nee
#### What the model sees
-The model receives the complete messages from `system/message`, `user/message`, `assistant/message`, and `tool/result` surface entries with logged projections applied, the system prompt first. Identities, roles, sources, and unmodified blocks retain their original values; projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their attribution. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message.
+The model receives the complete messages from `system/message`, `developer/message`, `user/message`, `assistant/message`, and `tool/result` surface entries with logged projections applied, the system prompt first. Identities, roles, sources, and unmodified blocks retain their original values; projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their attribution. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message.
#### Token effect
@@ -147,11 +149,11 @@ Appended surface entries are resent on later steps. A `replace` surface operatio
Appended surface entries preserve reusable prefixes. A `replace` operation invalidates reuse from the first shadowed message even though the underlying event log stays append-only.
-### Crash-repair result
+### Crash-repair and fork results
#### What the model sees
-If recovery finds an assistant tool request with no durable `tool/call`, its synthetic `TOOL_NOT_STARTED` result says `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.` If a durable `tool/call` has no result, its `TOOL_OUTCOME_UNKNOWN` result says `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.`
+If recovery finds an assistant tool request with no durable `tool/call`, its synthetic `TOOL_NOT_STARTED` result says `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.` If a durable `tool/call` has no result, its `TOOL_OUTCOME_UNKNOWN` result says `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.` Fork-generated results describe only the inherited records: the parent may have started or completed a call after the selected event. `TOOL_NOT_STARTED` means the prefix contains no start record; `TOOL_OUTCOME_UNKNOWN` means it contains a start but no result. Both tell the model to retry only read-only or idempotent operations without further checks; operations with side effects require external verification or user input. See the [fork decision](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.md).
#### Token effect
@@ -182,8 +184,8 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
These limits define when the session store needs special care. They are current package constraints, not a task backlog.
-- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the fork API.
-- **`SESSION_FORMAT_VERSION` names the [current logical representation](../../../docs/session-format-status.md)** — the current reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the adjacent migration chain converts supported history before constructing `Session`, and the write-open path publishes only the current-format successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
+- **Live-source Store API** — persisted-but-unloaded sources use the Host observation path. Fork closes only the open tail and does not repair missing results in previously closed steps.
+- **`SESSION_FORMAT_VERSION` names the [current logical representation](../../../docs/session-format-status.md)** — the current reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the [adjacent migration chain](../../session/session-format-catalog/README.md) converts supported history before constructing `Session`, and the write-open path publishes only the current-format successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
- **No session tree beyond fork** — a pi-style entry tree over branched sessions is deferred unless a consumer needs more than boundary-based forking.
diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md
index 9ab4ed4d08da..99ad04e06d10 100644
--- a/packages/core/session/README.zh.md
+++ b/packages/core/session/README.zh.md
@@ -29,7 +29,7 @@ kind: "package-reference"
### 创建与检查会话
-`ctx.sessions.create()` 构建绑定到调用方 fiber 的实时会话;`get(id)` 与 `list()` 查找会话,`fork()` 从实时会话的稳定前缀创建子会话。
+`ctx.sessions.create()` 构建绑定到调用方 fiber 的实时会话;`get(id)` 与 `list()` 查找会话,`fork()` 从实时会话的精确前缀创建子会话。
```text
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/workspace' } })
@@ -47,11 +47,13 @@ session.append('user/message', { role: 'user', content: [{ type: 'text', text: '
session.deriveMessages() // the derived model history
```
-表层事件(`system/message`、`user/message`、`assistant/message`、`tool/result`)在类型化事件与追加输入中都必须带有 `surfaceOp`。替换操作仅接受 `{ op: 'replace', startSeq, endSeq }`,端点为包含边界的 `SessionSeq`,按当前 surface 顺序解释。assistant 消息会嵌入精确、紧凑的提供方流,并禁止 `sourceEventSeqs`。已知仅日志事件禁止这两个元数据字段,且从不产生消息。
+表层事件(`system/message`、`developer/message`、`user/message`、`assistant/message`、`tool/result`)在类型化事件与追加输入中都必须带有 `surfaceOp`。替换操作仅接受 `{ op: 'replace', startSeq, endSeq }`,端点为包含边界的 `SessionSeq`,按当前 surface 顺序解释。assistant 消息会嵌入精确、紧凑的提供方流,并禁止 `sourceEventSeqs`。已知仅日志事件禁止这两个元数据字段,且从不产生消息。
+
+`developer/message` 存储工具添加名称,以及指向更早 `request/header` 的 `headerSeq` 引用。仅在存在添加块时必须携带该引用,每个名称必须在所引用的请求头中恰好对应一个完整模式。移除块仅记录工具名称,不携带请求头引用。重启、fork 和 surface 替换保留历史请求头;较早添加的定义不由当前注册表或最新请求头决定。`sourceEventSeqs` 继续描述派生来源及被替换节点。
插件用 `@messageProjection` 声明修改内容的事件,并通过 `ctx.sessions.registerMessageProjection()` 注册纯处理器。Session 在接受事件前调用处理器,并缓存其不可变消息更新。缺少处理器时拒绝追加和恢复,卸载已经使用的处理器后也会拒绝读取缓存。独立构造函数和 `foldSurface(events, projections)` 必须显式接收处理器。重建函数将折叠结果的 `projectedMessages` 传给 `deriveEventMessage()`,实时实例方法自动应用相同的投影。[插件拥有消息投影](../../../.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md)说明职责划分和离线装配。
-追加、seed/restore 与事件 adoption/snapshot 会拒绝任何 `header.system` 及恰好为空的可选请求头字段(`tools: []`、`adapterDefaults: {}`),而不规范化输入。工具结果的 `data.error` 仅在 `message.content[0].isError === true` 时允许存在;失败标识仍是可选的。被拒绝的追加不会改变日志、派生状态或事件流。Adoption 校验事件局部元数据,但不校验所引用的历史或替换端点是否属于 surface。
+追加、seed/restore 与事件 adoption/snapshot 会拒绝任何 `header.system` 及恰好为空的可选请求头字段(`tools: []`、`adapterDefaults: {}`),而不规范化输入。工具结果的 `data.error` 仅在一等消息的 `isError === true` 时允许存在;失败标识仍是可选的。被拒绝的追加不会改变日志、派生状态或事件流。Adoption 校验事件局部元数据,但不校验所引用的历史或替换端点是否属于 surface。
`system/message` 承载渲染后的系统提示词:第一条是 surface 第 0 号节点,准入依据已准备调用的能力,不具备能力的路由将非空渲染文本归并到首个系统节点,延续中的 `in-history` 序列则在缓存历史之后追加;空系统节点不投影为消息,因此清除提示词必须为所有生效的系统节点记录空内容替换,而非仅替换最新节点;当第 0 号节点是 `system/message` 时,surface 折叠拒绝覆盖它的替换,除非替换事件本身是恰好覆盖该节点的 `system/message`,而后续系统节点不受保护,压缩范围可以遮蔽它们([决策](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md))。
@@ -63,7 +65,7 @@ session.deriveMessages() // the derived model history
### 派生会话的 fork
-`ctx.sessions.fork(source, boundary?, childSessionId?)` 选取截至 `boundary` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。
+`ctx.sessions.fork(source, boundary?, childSessionId?)` 从实时源会话复制包含切点的精确事件前缀(默认:最后一个事件)。`dsh-session/fork` 的 `buildForkSeed` 在复制事件之后放置继承标记,仅为开放步骤补缺失的错误工具结果,并以 `forked` 原因关闭步骤和轮次。已关闭的步骤与轮次保持原样,包括历史缺失结果。标记和结束事件属于子会话;`inheritedEventCount` 只统计复制的前缀。
逻辑 `SessionHeader.isSeeded` 字段报告是否存在 fork 历史,而不公开位置整数。`Session.inheritedEventCount` 保留经过校验的精确 `SessionLogOffset`;`ownEvents()` 返回从该切点开始的事件,`isOwnSeq(seq)` 只接受已存在且由子会话拥有的位置。底层带 seed 构造必须显式提供 `seed` 与 `inheritedEventCount`,因为构造 seed 可以在继承前缀之后包含子会话自有的设置事件。
@@ -107,7 +109,7 @@ session.deriveMessages() // the derived model history
### 派生历史
-`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的系统节点不派生消息。插件拥有的投影修改派生内容,不修改记录的消息。替换和投影决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。
+`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。surface 事件类型(`system/message`、`developer/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的 system 和 developer 节点不派生消息。插件拥有的投影修改派生内容,不修改记录的消息。替换和投影决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。
### 请求头
@@ -137,7 +139,7 @@ session.deriveMessages() // the derived model history
#### 模型看到什么
-模型会接收 `system/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的消息,并应用日志中的投影,系统提示词在先。消息标识、角色、来源及未修改的内容块保持不变,投影不生成标识。直接提示词与注入上下文仍是独立的 `user/message` 事件,各事件的来源保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不添加消息。
+模型会接收 `system/message`、`developer/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的消息,并应用日志中的投影,系统提示词在先。消息标识、角色、来源及未修改的内容块保持不变,投影不生成标识。直接提示词与注入上下文仍是独立的 `user/message` 事件,各事件的来源保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不添加消息。
#### Token 影响
@@ -153,6 +155,8 @@ session.deriveMessages() // the derived model history
如果恢复发现 assistant 工具请求没有持久 `tool/call`,其合成 `TOOL_NOT_STARTED` 结果内容为 `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`。如果持久 `tool/call` 没有结果,其 `TOOL_OUTCOME_UNKNOWN` 结果内容为 `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.`。
+Fork 生成的结果只描述继承记录:父会话可能已经在所选事件之后启动或完成调用。`TOOL_NOT_STARTED` 表示前缀中没有启动记录;`TOOL_OUTCOME_UNKNOWN` 表示有启动记录但没有结果。两者都要求模型仅对只读或幂等操作直接重试;有副作用的操作需要先验证外部状态或询问用户。参见 [fork 决策](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md)。
+
#### Token 影响
未受损会话的 token 增量为零。恢复时,每个修复后的调用都会添加保留的、针对具体风险的错误文本。
@@ -182,8 +186,8 @@ session.deriveMessages() // the derived model history
这些限制说明会话存储何时需要特别留意。它们是当前包约束,不是任务积压。
-- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;fork API 不支持对已持久化但未加载的会话进行 fork。
-- **`SESSION_FORMAT_VERSION` 命名[当前逻辑表示](../../../docs/session-format-status.zh.md)**——当前读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;相邻迁移链在构造 `Session` 前转换受支持的历史,写打开只发布当前格式的后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
+- **Store API 仅接受活跃源会话** — 已持久化但未加载的源会话通过 Host 观察路径处理。Fork 仅闭合开放尾部,不修复先前已闭合步骤中缺失的结果。
+- **`SESSION_FORMAT_VERSION` 命名[当前逻辑表示](../../../docs/session-format-status.zh.md)**——当前读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;[相邻迁移链](../../session/session-format-catalog/README.zh.md)在构造 `Session` 前转换受支持的历史,写打开只发布当前格式的后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
- **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
- **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。
diff --git a/packages/core/session/package.json b/packages/core/session/package.json
index 2164e9abad3c..709b341345bc 100644
--- a/packages/core/session/package.json
+++ b/packages/core/session/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-session",
"description": "Event-sourced session store for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
@@ -26,6 +26,10 @@
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
+ "./fork": {
+ "types": "./lib/types/fork.d.ts",
+ "default": "./lib/types/fork.js"
+ },
"./src/*": "./src/*",
"./package.json": "./package.json",
"./surface": {
diff --git a/packages/core/session/src/fork.ts b/packages/core/session/src/fork.ts
new file mode 100644
index 000000000000..a30801d6205b
--- /dev/null
+++ b/packages/core/session/src/fork.ts
@@ -0,0 +1,30 @@
+/**
+ * Fork seed construction over an exact source-event prefix.
+ * @module @mutantcat/dsh-session/fork
+ */
+
+import { openTurnClosers } from './repair.ts'
+import { SessionSeq } from './types.ts'
+import type { SessionEvent, SessionSeq as SessionSeqType } from './types.ts'
+
+/**
+ * Copy an inclusive event prefix, mark its inherited cut, and close its open tail with forked results
+ * and step/turn endings. Closed steps and turns are preserved unchanged.
+ * The caller validates that the boundary is an existing contiguous event seq;
+ * Session construction snapshots the borrowed events before publication.
+ *
+ * @param events - source log with contiguous seqs from zero.
+ * @param boundary - inclusive source event seq the child inherits through.
+ * @returns a new array retaining the source event objects, followed by synthetic
+ * closers outside the inherited prefix counted by `inheritedEventCount`.
+ */
+export function buildForkSeed(events: readonly SessionEvent[], boundary: SessionSeqType): SessionEvent[] {
+ const prefix = events.slice(0, boundary + 1)
+ prefix.push({
+ type: 'session/end-seed', seq: SessionSeq(boundary + 1),
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- the caller validated this exact source event.
+ time: events[boundary]!.time,
+ data: { inherited: true },
+ })
+ return prefix.concat(openTurnClosers(prefix, { kind: 'forked' }))
+}
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index 6dcc070f9b38..c022d39236a9 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -20,10 +20,13 @@ import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } fro
import type { SessionSurface, SessionMessageProjection } from './surface.ts'
import { foldRequestHeader } from './request-header.ts'
+import { buildForkSeed } from './fork.ts'
+
+export { buildForkSeed } from './fork.ts'
export * from './types.ts'
export { SessionPreparation } from './preparation.ts'
export type { SessionPreparationOptions } from './preparation.ts'
-export type { AssistantMessage, SystemMessage, ToolResultMessage, UserMessage } from '@mutantcat/dsh-llm'
+export type { AssistantMessage, DeveloperMessage, SystemMessage, ToolResultMessage, UserMessage } from '@mutantcat/dsh-llm'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult, SessionMessageProjection, SessionMessageProjectionContext } from './surface.ts'
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
@@ -174,6 +177,7 @@ export function adoptSessionEvent(event: T): T {
case 'user/message':
deepFreeze(event.data)
break
+ case 'developer/message':
case 'system/message':
case 'assistant/message':
case 'tool/result':
@@ -228,6 +232,7 @@ function assertSessionEventEnvelope(value: unknown, index: number): asserts valu
validateSessionEventData(event as SessionEvent, `seed ${type} at index ${index}`)
switch (type) {
case 'request/header':
+ case 'developer/message':
case 'system/message':
case 'user/message':
case 'assistant/attempt':
@@ -311,17 +316,18 @@ function assertAdapterDefaults(
}
}
-/** The four surface event types whose payload carries an identified message. */
+/** The surface event types whose payload carries an identified message. */
function isMessageEventType(type: unknown): type is SurfaceEventType {
- return type === 'system/message' || type === 'user/message'
+ return type === 'developer/message' || type === 'system/message' || type === 'user/message'
|| type === 'assistant/message' || type === 'tool/result'
}
const MESSAGE_ROLE_BY_TYPE: Record = {
'system/message': 'system',
+ 'developer/message': 'developer',
'user/message': 'user',
'assistant/message': 'assistant',
- 'tool/result': 'user',
+ 'tool/result': 'tool',
}
/** Validate only the event-specific invariants needed to safely replay a message. */
@@ -354,9 +360,8 @@ function assertMessageEventShape(event: Record, subject: string
}
const sourceRecord = source as Record
if (type === 'system/message') {
- if (sourceRecord['kind'] !== 'plugin' || typeof sourceRecord['plugin'] !== 'string'
- || sourceRecord['plugin'] === '') {
- throw new Error(`${subject} message must have plugin source`)
+ if (sourceRecord['kind'] !== 'system-prompt') {
+ throw new Error(`${subject} message must have system-prompt source`)
}
return
}
@@ -372,14 +377,7 @@ function assertMessageEventShape(event: Record, subject: string
|| sourceRecord['callId'] === '') {
throw new Error(`${subject} message must have tool source`)
}
- const content = messageRecord['content'] as unknown[]
- const block = content[0]
- if (content.length !== 1 || typeof block !== 'object' || block === null
- || (block as Record)['type'] !== 'tool-result'
- || !Array.isArray((block as Record)['content'])) {
- throw new Error(`${subject} message must contain one tool-result block`)
- }
- if ((block as Record)['toolCallId'] !== sourceRecord['callId']) {
+ if (messageRecord['toolCallId'] !== sourceRecord['callId']) {
throw new Error(`${subject} message has mismatched tool call ids`)
}
}
@@ -472,30 +470,27 @@ export class Session {
}
/**
- * The first seq appended IN THIS PROCESS: the length of the constructor
- * seed (0 without one). Events with smaller seq values entered through
- * construction — replay, fork, or resume — and were never published on the
- * `session/event` firehose (constructor seeds do not emit). This offset marks
- * the constructor-input boundary for lifecycle ownership and persistence
- * adoption; consumers that need complete canonical history still start at
- * seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
- * fork-lineage cut: a resumed session's constructor seed is its full stored
- * log, while the inherited count keeps the original fork value — this field is the
- * in-process construction fact.
- *
- * Not persisted itself: a seeded session projects it into the log as the
- * `session/end-seed` event, which is what a consumer reading STORED history
- * reads. Locate the LAST such event, not necessarily one at this seq — a
- * seed already ending in one is not re-marked, so reopening an untouched
- * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
- * this field in-process: it is exact before the marker reaches storage.
+ * The constructor seed length (0 without one), before any marker appended
+ * during construction. Seed events never publish on `session/event`. A
+ * marker appended before the store attaches occupies this seq without
+ * publishing either; otherwise this seq is available for the next append.
*
- * When this lifecycle appends the marker, it occupies this seq before the
- * store attaches and therefore does not publish either. Otherwise this seq
- * holds an ordinary published write.
+ * This in-process offset is not persisted. A fork seed can already contain
+ * the child's inherited marker and synthetic closers, so its child-owned
+ * history starts at {@link inheritedEventCount}, before this offset. A
+ * resumed Session's seed contains its full stored log, while its inherited
+ * count keeps the durable fork cut. Consumers needing complete canonical
+ * history start at seq 0.
*/
readonly firstLiveSeq: SessionLogOffset
+ /**
+ * First event produced for this object lifecycle. A new fork includes its
+ * child-owned seed marker and closers; a restored Session starts after its
+ * complete stored prefix. This in-process capture offset is not persisted.
+ */
+ readonly firstLifecycleSeq: SessionLogOffset
+
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.
@@ -605,16 +600,22 @@ export class Session {
if (inheritedEventCount > this.log.length) {
throw new Error('session inherited event count exceeds its event log')
}
- if (mode === 'snapshot' && this.header.isSeeded && inheritedEventCount !== this.log.length) {
- throw new Error('seeded session constructor seed must equal its inherited prefix')
+ const seedMarker = this.log[inheritedEventCount]
+ const markedSeed = seedMarker?.type === 'session/end-seed' && seedMarker.data.inherited === true
+ if (mode === 'snapshot' && this.header.isSeeded && inheritedEventCount !== this.log.length && !markedSeed) {
+ throw new Error('seeded session constructor seed must equal its inherited prefix or mark its inherited cut')
+ }
+ if (markedSeed && this.log.slice(inheritedEventCount + 1).some(event => event.type === 'session/end-seed' && event.data.inherited === true)) {
+ throw new Error('session inherited event count must identify the final inherited marker')
}
this.inheritedEventCount = inheritedEventCount
+ this.firstLifecycleSeq = mode === 'snapshot' && this.header.isSeeded ? inheritedEventCount : this.firstLiveSeq
// A fresh seeded child always owns one tagged marker at its inherited cut,
// even when the copied prefix already ends in an ancestor marker. Restore
// retains that durable marker and appends only the ordinary resume marker.
- if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded) {
+ if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded && !markedSeed) {
this.append('session/end-seed', { inherited: true })
- } else if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
+ } else if (seed !== undefined && !(mode === 'snapshot' && this.header.isSeeded) && this.log.at(-1)?.type !== 'session/end-seed') {
this.append('session/end-seed', {})
}
}
@@ -879,16 +880,14 @@ export type SessionForkSource = Session | SessionId
* Rejection codes for session forking: the fork source id is unknown to the
* live store (`SESSION_NOT_FOUND`) or names a session object that is not the
* store's live instance (`SESSION_NOT_LIVE`); the requested child id is
- * already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
- * existing seq (`INVALID_BOUNDARY`); or the selected prefix ends inside an
- * open turn (`OPEN_TURN`).
+ * already taken (`SESSION_ALREADY_EXISTS`); or the boundary is not a contiguous
+ * existing seq (`INVALID_BOUNDARY`).
*/
export type SessionForkErrorCode =
| 'SESSION_NOT_FOUND'
| 'SESSION_NOT_LIVE'
| 'SESSION_ALREADY_EXISTS'
| 'INVALID_BOUNDARY'
- | 'OPEN_TURN'
/** Typed error for session fork rejections. */
export class SessionForkError extends Error {
@@ -1220,10 +1219,12 @@ export class SessionStore extends Service {
}
/**
- * Create a live child session from a stable prefix of a live source.
+ * Create a live child session from an exact prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
- * current last event. The selected slice may end with a between-turn event
- * but must not end inside an open turn.
+ * current last event. An open tail receives synthetic tool results and
+ * step/turn closers with the forked cause. Closed steps and turns remain
+ * unchanged, including any failed tool calls already missing results.
+ * `inheritedEventCount` counts only copied source events, excluding these closers.
*
* @param source - Live source session object or id.
* @param boundary - Inclusive source event seq to fork through; omitted means
@@ -1238,10 +1239,13 @@ export class SessionStore extends Service {
throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
}
const liveSource = this._resolveForkSource(source)
- const seed = this._forkSeed(liveSource, boundary)
+ // oxlint-disable-next-line typescript/no-deprecated -- Existing fork snapshot read; migration deferred.
+ const events = liveSource.snapshotEvents()
+ const resolved = this._forkBoundary(liveSource.id, events, boundary)
+ const seed = resolved === undefined ? [] : buildForkSeed(events, resolved)
return this.create(childSessionId, {
seed,
- inheritedEventCount: SessionLogOffset(seed.length),
+ inheritedEventCount: SessionLogOffset(resolved === undefined ? 0 : resolved + 1),
meta: {
...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
parentSession: liveSource.id,
@@ -1250,50 +1254,39 @@ export class SessionStore extends Service {
})
}
- private _forkSeed(session: Session, requestedBoundary: SessionSeq | undefined): readonly SessionEvent[] {
- // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
- const lastEvent = session.snapshotEvents().at(-1)
+ private _forkBoundary(
+ sessionId: SessionId, events: readonly SessionEvent[], requestedBoundary: SessionSeq | undefined,
+ ): SessionSeq | undefined {
+ const lastEvent = events.at(-1)
let boundary: SessionSeq
if (requestedBoundary !== undefined) {
boundary = requestedBoundary
} else {
- if (lastEvent === undefined) return []
+ if (lastEvent === undefined) return undefined
boundary = lastEvent.seq
}
if (!Number.isSafeInteger(boundary) || boundary < 0) {
throw new SessionForkError(
- `fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
+ `fork boundary for session "${sessionId}" must be a non-negative safe integer, got ${String(boundary)}`,
'INVALID_BOUNDARY',
)
}
- if (boundary >= session.seq) {
+ if (boundary >= events.length) {
const lastSeq = lastEvent?.seq
throw new SessionForkError(
- `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
+ `fork boundary ${boundary} does not exist in session "${sessionId}" (last seq: ${lastSeq ?? 'none'})`,
'INVALID_BOUNDARY',
)
}
- // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
- const boundaryEvent = session.eventAt(boundary)
+ const boundaryEvent = events[boundary]
if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
throw new SessionForkError(
- `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
+ `fork boundary ${boundary} does not match a contiguous event seq in session "${sessionId}"`,
'INVALID_BOUNDARY',
)
}
- // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
- const events = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(boundary + 1))
- const lastTurnBoundary = events
- .findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
- if (lastTurnBoundary?.type === 'turn/start') {
- throw new SessionForkError(
- `fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`,
- 'OPEN_TURN',
- )
- }
-
- return events
+ return boundary
}
private _resolveForkSource(source: SessionForkSource): Session {
diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts
index e119be9c3d90..e8bb91c9e0ec 100644
--- a/packages/core/session/src/invariant.ts
+++ b/packages/core/session/src/invariant.ts
@@ -104,6 +104,10 @@ function validateEvent(
openStep = event.data.step
break
}
+ case 'developer/message': {
+ requireOpenStep(trace, 'developer/message', event.data.turn, event.data.step, fail)
+ break
+ }
case 'step/end': {
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail)
pendingCalls = { kind: 'clear' }
@@ -135,7 +139,7 @@ function validateEvent(
}
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
const callId = event.data.message.source.callId
- const syntheticNotStarted = event.data.message.content[0].isError === true && event.data.error?.code === TOOL_NOT_STARTED
+ const syntheticNotStarted = event.data.message.isError === true && event.data.error?.code === TOOL_NOT_STARTED
if (!trace.pendingCalls.has(callId) && !syntheticNotStarted) {
fail(`tool/result for ${callId} with no prior tool/call in this step`)
}
diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts
index d2552cfc1387..4d39a7b85629 100644
--- a/packages/core/session/src/known-event-types.ts
+++ b/packages/core/session/src/known-event-types.ts
@@ -34,6 +34,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([
'compaction/start',
'compaction/summary',
'deliverables/presented',
+ 'developer/message',
'feedback/message-delete',
'feedback/message-put',
'feedback/record',
diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts
index 0a2cd09b9cd2..70581eb6f16f 100644
--- a/packages/core/session/src/repair.ts
+++ b/packages/core/session/src/repair.ts
@@ -1,7 +1,10 @@
/**
- * Crash-recovery repair for an interrupted session log. It preserves a fully
- * written final turn and supplies the missing tool, step, and turn boundaries
- * needed to resume with a provider-valid transcript.
+ * Synthetic closer events that balance a session log whose tail turn is open.
+ * Two producers share the mechanism: crash recovery closes an interrupted
+ * persisted log on reload, and fork-seed construction closes a prefix cut
+ * inside the source's open turn. Both preserve every fully written event and
+ * close the unfinished step and turn. Calls in already closed steps remain
+ * unchanged, including any missing results.
* @module @mutantcat/dsh-session/repair
*/
@@ -17,16 +20,47 @@ export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
/** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN'
+/**
+ * Why an open tail turn is closed with synthetic events: `interrupted` is
+ * crash recovery over a persisted log; `forked` is a fork seed cut inside the
+ * source's open turn. The cause selects the synthetic `turn/end` reason, the
+ * model-visible wording of synthetic error tool results, and the
+ * deterministic synthetic message-id prefix. The error codes
+ * ({@link TOOL_NOT_STARTED} / {@link TOOL_OUTCOME_UNKNOWN}) are shared: both
+ * causes state the same fact about the call's recorded lifecycle.
+ */
+export type OpenTurnCloseCause = { readonly kind: 'interrupted' } | { readonly kind: 'forked' }
+
+/** Model-visible wording of the synthetic error tool results, keyed by cause. */
+const CLOSER_TEXT = {
+ interrupted: {
+ started: 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
+ notStarted: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
+ },
+ forked: {
+ started: 'The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
+ notStarted: 'The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
+ },
+} as const
+
/**
* Return deterministic synthetic events that close an open tail turn. Unmatched
- * calls receive error results first, followed by an open `step/end` and an
- * interrupted `turn/end`; sequences continue the log and timestamps reuse the
- * last real event. A balanced or empty log returns no events.
+ * calls in its open step receive error results, followed by `step/end` and a
+ * `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
+ * Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
+ * events.
+ *
+ * Package-internal: each cause has exactly one owner, so external callers go
+ * through {@link interruptedTurnClosers} (persistence crash recovery) or
+ * `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
*
- * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
+ * @param events - the log to scan: a valid committed prefix, possibly ending
+ * inside an open turn (a crash tail or a mid-turn fork cut).
+ * @param cause - why the turn is being closed; selects the `turn/end` reason
+ * and the model-visible wording of synthetic error tool results.
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
*/
-export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
+export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): SessionEvent[] {
let openTurn: number | null = null
let openStep: number | null = null
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
@@ -76,7 +110,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
}
}
- // Balanced log (no crash mid-turn): nothing to close. An open turn implies
+ // Balanced log (no open tail turn): nothing to close. An open turn implies
// `events` is non-empty (its turn/start was logged), so `last` exists.
const last = events.at(-1)
if (openTurn === null || last === undefined) return []
@@ -88,24 +122,19 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
const time = last.time
const closers: SessionEvent[] = []
- // Close calls before their step: providers reject dangling assistant calls,
- // and Map insertion order preserves their transcript order.
+ // Close calls before their step; Map insertion order preserves transcript order.
+ const text = CLOSER_TEXT[cause.kind]
for (const [callId, { step, callSeq }] of pendingCalls) {
const started = callSeq !== undefined
const message: ToolResultMessage = deepFreeze({
- id: brandString(`interrupted-tool-result-${callId}-${seq}`),
- role: 'user',
+ id: brandString(`${cause.kind}-tool-result-${callId}-${seq}`),
+ role: 'tool',
+ toolCallId: callId,
+ isError: true,
source: { kind: 'tool', callId },
content: [{
- type: 'tool-result',
- toolCallId: callId,
- isError: true,
- content: [{
- type: 'text',
- text: started
- ? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
- : 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
- }],
+ type: 'text',
+ text: started ? text.started : text.notStarted,
}],
})
closers.push({
@@ -130,6 +159,19 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
if (openStep !== null) {
closers.push({ type: 'step/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, step: openStep } })
}
- closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: 'interrupted' } } })
+ closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: cause.kind } } })
return closers
}
+
+/**
+ * Crash-recovery entry point: synthetic closers that balance a persisted log
+ * whose tail turn was interrupted. Used by crash-recovery callers; fork
+ * seeds receive their `forked`-cause closers through `buildForkSeed` in
+ * `./fork.ts`, and cause selection stays internal to those two owners.
+ *
+ * @param events - the persisted log to scan, possibly ending inside an open turn.
+ * @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
+ */
+export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
+ return openTurnClosers(events, { kind: 'interrupted' })
+}
diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts
index 7501586de0b2..b370577eb4e0 100644
--- a/packages/core/session/src/surface.ts
+++ b/packages/core/session/src/surface.ts
@@ -8,7 +8,7 @@
* @module @mutantcat/dsh-session/surface
*/
-import type { Message } from '@mutantcat/dsh-llm'
+import type { Message, ToolSchema } from '@mutantcat/dsh-llm'
import { SessionLogOffset, SessionSeq } from './types.ts'
import { KNOWN_SESSION_EVENT_TYPES, MESSAGE_PROJECTION_EVENT_TYPES } from './known-event-types.ts'
import type {
@@ -49,6 +49,7 @@ export interface SessionMessageProjection([
'system/message',
+ 'developer/message',
'user/message',
'assistant/message',
'tool/result',
@@ -57,7 +58,7 @@ const SURFACE_EVENT_TYPES = new Set([
/**
* Whether an event type can join the model-visible surface.
* @param type - event type to test.
- * @returns true for one of the four message-producing event types.
+ * @returns true for one of the message-producing event types.
*/
export function isSurfaceEligibleType(type: string): boolean {
return SURFACE_EVENT_TYPES.has(type)
@@ -107,7 +108,7 @@ export function isReplacementSurfaceEvent(
/**
* Project a single event into the LLM message it derives to, or null when it
* produces none — a non-surface event (attempt, boundary, log-only record) or an
- * empty-content assistant/message (which exists only to host usage). A caller
+ * empty-content system, developer, or assistant message. A caller
* reconstructing model input supplies the same prefix's `projectedMessages`
* from {@link foldSurface}; without that map this function reads original
* event content. Session instance methods apply the live projection. Messages
@@ -137,12 +138,11 @@ export function deriveEventMessage(
case 'user/message': {
return event.data
}
- // An empty-content message projects to no wire message. For
- // system/message the node records "no system prompt" while keeping its
- // surface position; for assistant/message the event exists only to host a
- // max-tokens step's usage and must not inject a content-less assistant
- // turn into the provider transcript.
+ // Empty system and developer nodes retain their surface positions without
+ // adding wire messages. An empty assistant event hosts a max-tokens step's
+ // usage and must not inject a content-less turn into the provider transcript.
case 'system/message':
+ case 'developer/message':
case 'assistant/message': {
if (event.data.message.content.length === 0) return null
return event.data.message
@@ -163,17 +163,46 @@ function isRecord(value: unknown): value is Record {
}
/**
- * Reject noncanonical request-header fields and contradictory tool failure metadata.
+ * Reject noncanonical request-header fields, developer roles/content, and contradictory tool failure metadata.
* This does not validate complete event payloads or embedded provider streams.
* @param event - event whose locally related payload fields are inspected.
* @param subject - event location to include in validation errors.
- * @throws when request data/header is not an object, optional header fields are empty, or tool failure metadata contradicts its message.
+ * @throws when request-header fields, developer roles/content, or tool failure metadata are invalid.
*/
export function validateSessionEventData(
event: Pick,
subject: string,
): void {
const data: unknown = event.data
+ if (SURFACE_EVENT_TYPES.has(event.type) && isRecord(data)) {
+ const message = event.type === 'user/message' ? data : data['message']
+ if (isRecord(message)) {
+ if ((event.type === 'developer/message') !== (message['role'] === 'developer')) {
+ throw new Error(`${subject} developer/message and developer role must occur together`)
+ }
+ if (message['role'] !== 'developer' && Array.isArray(message['content'])
+ && message['content'].some((block: unknown) => isRecord(block)
+ && (block['type'] === 'tool-addition' || block['type'] === 'tool-removal'))) {
+ throw new Error(`${subject} tool-change blocks require developer role`)
+ }
+ if (event.type === 'developer/message' && Array.isArray(message['content'])) {
+ let hasAdditions = false
+ for (const block of message['content']) {
+ if (!isRecord(block) || (block['type'] !== 'tool-addition' && block['type'] !== 'tool-removal')) continue
+ if (typeof block['toolName'] !== 'string' || block['toolName'].length === 0) {
+ throw new Error(`${subject} ${block['type']} requires a nonempty toolName`)
+ }
+ if (block['type'] === 'tool-addition') {
+ hasAdditions = true
+ if (Object.hasOwn(block, 'tool')) throw new Error(`${subject} tool-addition must omit inline tool definitions`)
+ }
+ }
+ if (hasAdditions ? !isEventSeq(data['headerSeq']) : Object.hasOwn(data, 'headerSeq')) {
+ throw new Error(`${subject} requires headerSeq exactly when tool additions are present`)
+ }
+ }
+ }
+ }
if (event.type === 'request/header') {
if (!isRecord(data)) throw new Error(`${subject} data must be an object`)
const header = data['header']
@@ -190,10 +219,8 @@ export function validateSessionEventData(
if (!isRecord(data)) throw new Error(`${subject} data must be an object`)
if (data['error'] === undefined) return
const message = data['message']
- const content = isRecord(message) ? message['content'] : undefined
- const block: unknown = Array.isArray(content) ? content[0] : undefined
- if (!isRecord(block) || block['isError'] !== true) {
- throw new Error(`${subject} error requires message content[0].isError === true`)
+ if (!isRecord(message) || message['isError'] !== true) {
+ throw new Error(`${subject} error requires message.isError === true`)
}
}
}
@@ -343,6 +370,36 @@ function assertSourceEventReferences(
}
}
+/** Resolve tool additions against their immutable historical request header. */
+function assertDeveloperHeader(
+ event: SessionEvent,
+ events: readonly SessionEvent[],
+ baseSeq: SessionLogOffset,
+): void {
+ if (event.type !== 'developer/message') return
+ validateSessionEventData(event, `developer/message at seq ${event.seq}`)
+ if (event.data.headerSeq === undefined) return
+ const headerSeq = event.data.headerSeq
+ const headerEvent = events[headerSeq - baseSeq]
+ if (headerSeq >= event.seq || headerEvent?.type !== 'request/header') {
+ throw new Error('developer/message headerSeq must reference an earlier request/header')
+ }
+ for (const block of event.data.message.content) {
+ if (block.type !== 'tool-addition') continue
+ const definitions = headerEvent.data.header.tools?.filter(tool => tool.name === block.toolName) ?? []
+ if (definitions.length !== 1) {
+ throw new Error(`developer/message tool-addition "${block.toolName}" must name exactly one tool in headerSeq ${headerSeq}`)
+ }
+ const definition = definitions[0] as ToolSchema
+ if (typeof definition.description !== 'string' || !isRecord(definition.parameters)) {
+ throw new Error(`developer/message tool-addition "${block.toolName}" requires a complete tool definition in headerSeq ${headerSeq}`)
+ }
+ if (Object.hasOwn(definition, 'deferLoading') && definition.deferLoading !== true) {
+ throw new Error('developer/message referenced tool deferLoading must be true when present')
+ }
+ }
+}
+
/**
* Validate one event's surface metadata without checking membership in a log or surface.
* @param event - event whose marker and source sequence values are inspected.
@@ -419,15 +476,13 @@ function assertToolResultRewrite(
}
const originalRest = { ...original.data } as Record
const replacementRest = { ...event.data } as Record
- const originalResult = original.data.message.content[0]
- const replacementResult = event.data.message.content[0]
originalRest['message'] = {
...original.data.message,
- content: [{ ...originalResult, content: null }],
+ content: null,
}
replacementRest['message'] = {
...event.data.message,
- content: [{ ...replacementResult, content: null }],
+ content: null,
}
if (!isDeepEqualJson(originalRest, replacementRest)) {
throw new Error('tool/result surface replacement may change only content')
@@ -470,6 +525,7 @@ function planSurfaceEvent(
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
}
const surfaceOp = validateSurfaceMetadata(event)
+ assertDeveloperHeader(event, events, baseSeq)
const projection = projections.find(item => item.type === event.type)
if (projection !== undefined) {
return { kind: 'project', projection, messages: projection.project(event, {
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index 1e334518f390..e2b006680105 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -1,6 +1,7 @@
import { brandNumber, brandString, type Branded, type BrandedNumber } from '@mutantcat/dsh-brand'
import type {
AssistantMessage,
+ DeveloperMessage,
AssistantStreamRecord,
ToolCallId,
LlmCallConfig,
@@ -85,7 +86,7 @@ export type OptionalSessionSeq = SessionSeq | null
* immutable prior-generation, and current fast-path rules are recorded in
* `.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md`.
*/
-export const SESSION_FORMAT_VERSION = 3
+export const SESSION_FORMAT_VERSION = 4
/**
* Immutable validated storage metadata, kept outside the conversation event log.
@@ -139,8 +140,8 @@ export interface CreateSessionOptions {
readonly seed?: readonly SessionEvent[]
/**
* Exact fork-inherited prefix length when `meta.isSeeded` is true. The
- * constructor seed is exactly this inherited prefix; the constructor
- * appends the child-owned tagged marker at the cut.
+ * constructor appends the child-owned tagged marker at the cut unless
+ * the seed already includes it followed by child-owned fork closers.
*/
readonly inheritedEventCount?: SessionLogOffset
/**
@@ -218,6 +219,13 @@ export interface TurnEndReasonMap {
* the events recorded before the crash remain intact.
*/
interrupted: { kind: 'interrupted' }
+ /**
+ * Fork-seed construction closed a turn that was still open at the fork
+ * boundary in the source session. Only fork seeds carry this marker — the
+ * loop never emits it — and the source events before the boundary remain
+ * intact in the child.
+ */
+ forked: { kind: 'forked' }
}
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
@@ -295,6 +303,14 @@ export interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
+ /** An incremental agent session change admitted at the named turn and step. */
+ 'developer/message': {
+ turn: number
+ step: number
+ message: DeveloperMessage
+ /** Earlier request/header defining every tool addition; required exactly when additions are present. */
+ headerSeq?: SessionSeq
+ }
/**
* The rendered system prompt on the model-visible surface. The loop appends
* the first one as surface node 0 before the step's first `user/message`.
@@ -358,7 +374,7 @@ export interface SessionEventMap {
message: ToolResultMessage
/**
* Optional failure identity and raw user-facing reason, outside model content;
- * allowed only when the tool-result block has `isError: true`.
+ * allowed only when the message has `isError: true`.
*/
error?: { name: string; code: string; reason?: string }
meta?: JsonValue
@@ -416,6 +432,7 @@ export type SessionEventType = keyof SessionEventMap
*/
export type SurfaceEventType =
| 'system/message'
+ | 'developer/message'
| 'user/message'
| 'assistant/message'
| 'tool/result'
diff --git a/packages/core/session/tests/canonical-envelopes.spec.ts b/packages/core/session/tests/canonical-envelopes.spec.ts
index 45920abc1c3b..66ceece1eca8 100644
--- a/packages/core/session/tests/canonical-envelopes.spec.ts
+++ b/packages/core/session/tests/canonical-envelopes.spec.ts
@@ -72,7 +72,7 @@ const invalidData = [
rule: /must omit header.system/,
})),
{ name: 'successful tool has failure metadata', event: toolEvent({ ...toolData(false), error: failure }), rule: /error requires/ },
- ...[undefined, null, [], {}, { content: null }, { content: [] }, { content: [null] }, { content: [{ isError: 'true' }] }].map(message => ({
+ ...[undefined, null, [], {}, { isError: 'true' }, { content: [{ isError: true }] }].map(message => ({
name: 'malformed tool message with failure metadata',
event: toolEvent({ turn: 1, step: 1, ...message === undefined ? {} : { message }, error: failure }),
rule: /error requires/,
@@ -80,6 +80,21 @@ const invalidData = [
]
describe('canonical event payload acceptance', () => {
+ it('restores system prompts only with system-prompt producer attribution', () => {
+ const message = createSystemMessage('prompt')
+ const event: SessionEvent<'system/message'> = {
+ type: 'system/message', seq: SessionSeq(0), time: 1, surfaceOp: 'append',
+ data: { turn: 1, step: 1, message },
+ }
+ for (const accept of [entryPaths.seed!, entryPaths.restore!, entryPaths.adopt!, entryPaths.snapshot!]) {
+ expect(() => accept(structuredClone(event))).not.toThrow()
+ for (const source of [{ kind: 'runtime-context' }, { kind: 'plugin', plugin: '@mutantcat/dsh-system-prompt' }, { kind: 'user' }]) {
+ const invalid = { ...event, data: { ...event.data, message: { ...message, source } } }
+ expect(() => accept(invalid as unknown as SessionEvent)).toThrow(/system-prompt source/)
+ }
+ }
+ })
+
for (const [path, accept] of Object.entries(entryPaths)) {
describe(path, () => {
it.each(invalidData)('rejects $name with a located error', ({ event, rule }) => {
@@ -107,7 +122,7 @@ describe('canonical event payload acceptance', () => {
}
it('preserves nested header, source, and data extras without normalizing accepted events', () => {
- const message = createSystemMessage('prompt', 'fixture')
+ const message = createSystemMessage('prompt')
const events: SessionEvent[] = [
requestEvent({ header: { config, extra: { nested: [true, null] } }, reason: 'initial', extra: ['retained'] }),
{ type: 'system/message', seq: SessionSeq(0), time: 1, surfaceOp: 'append', data: {
@@ -295,9 +310,9 @@ describe('canonical event-local surface metadata', () => {
it('requires system placement and preserves system data and source-event references on head replacements', () => {
const session = Session.create(id)
- const data = { turn: 1, step: 1, message: createSystemMessage('head', 'fixture'), extra: { nested: true } }
+ const data = { turn: 1, step: 1, message: createSystemMessage('head'), extra: { nested: true } }
const head = session.append('system/message', data, { surfaceOp: 'append' })
- const next = session.append('system/message', { ...data, message: createSystemMessage('next', 'fixture') }, {
+ const next = session.append('system/message', { ...data, message: createSystemMessage('next') }, {
surfaceOp: { op: 'replace', startSeq: head.seq, endSeq: head.seq }, sourceEventSeqs: [head.seq],
})
expect(next.data).toEqual({ ...data, message: next.data.message })
diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts
index f24bebdb3514..2a63cd9a014b 100644
--- a/packages/core/session/tests/derived-cache.spec.ts
+++ b/packages/core/session/tests/derived-cache.spec.ts
@@ -1,4 +1,5 @@
import { createUserMessage, createMessage } from '@mutantcat/dsh-llm'
+import type { MessageSource } from '@mutantcat/dsh-llm'
/**
* Derived-message cache contract against a scratch oracle: project new nodes
* once, rebuild on surface replacements, return fresh arrays over shared
@@ -8,6 +9,13 @@ import { createUserMessage, createMessage } from '@mutantcat/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Session, SessionId } from '@mutantcat/dsh-session'
+type CheckpointSource = Extract
+
+/** Build a typed checkpoint source for a derived-message fixture. */
+function checkpointSource(compactionId: string): CheckpointSource {
+ return { kind: 'compact-checkpoint', compactionId: compactionId as CheckpointSource['compactionId'] }
+}
+
function userText(session: Session, text: string): void {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
@@ -65,7 +73,7 @@ describe('derived-message cache', () => {
const nodes = session.surface.nodes
session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
+ content: [{ type: 'text', text: 'summary' }], source: checkpointSource('derived-cache-compaction'),
}), { surfaceOp: { op: 'replace', startSeq: nodes[0]!, endSeq: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(session.deriveMessages()).toHaveLength(1)
diff --git a/packages/core/session/tests/developer-header.spec.ts b/packages/core/session/tests/developer-header.spec.ts
new file mode 100644
index 000000000000..c06e159c2c6d
--- /dev/null
+++ b/packages/core/session/tests/developer-header.spec.ts
@@ -0,0 +1,109 @@
+/** Tool additions bind to one historical request header across Session lifecycle paths. */
+import { describe, expect, it } from 'vitest'
+import { createDeveloperMessage } from '@mutantcat/dsh-llm'
+import type { ToolSchema } from '@mutantcat/dsh-llm'
+import { Session, SessionId, SessionLogOffset, SessionSeq } from '@mutantcat/dsh-session'
+import type { SessionEvent } from '@mutantcat/dsh-session'
+import { buildForkSeed } from '@mutantcat/dsh-session/fork'
+import { foldSurface } from '../src/surface.ts'
+
+const first: ToolSchema = { name: 'search', description: 'First', parameters: { type: 'object' } }
+const second: ToolSchema = { name: 'search', description: 'Second', parameters: { properties: { query: { type: 'string' } } } }
+const addition = () => createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-addition', toolName: 'search' }] })
+
+function opened(tools: ToolSchema[] = [first]) {
+ const session = Session.create(SessionId('developer-header'))
+ session.append('turn/start', { turn: 1 })
+ session.append('step/start', { turn: 1, step: 1 })
+ const headerSeq = session.append('request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools } }).seq
+ return { session, headerSeq }
+}
+
+describe('developer request-header references', () => {
+ it('keeps an older header binding through compound changes, replacement, restore, and fork', () => {
+ const { session, headerSeq } = opened([first, { name: 'other', description: 'Other', parameters: {} }])
+ session.append('request/header', { reason: 'change', header: { config: { provider: 'test', model: 'test' }, tools: [second] } })
+ const message = createDeveloperMessage({ source: { kind: 'test' }, content: [
+ { type: 'tool-removal', toolName: 'search' },
+ { type: 'tool-addition', toolName: 'search' },
+ { type: 'tool-addition', toolName: 'other' },
+ ] })
+ const initial = session.append('developer/message', { turn: 1, step: 1, headerSeq, message }, { surfaceOp: 'append' })
+ const replacement = session.append('developer/message', { turn: 1, step: 1, headerSeq, message: addition() }, {
+ surfaceOp: { op: 'replace', startSeq: initial.seq, endSeq: initial.seq }, sourceEventSeqs: [initial.seq],
+ })
+ const events = session.snapshotEvents()
+ expect(events[headerSeq]).toMatchObject({ data: { header: { tools: [first, { name: 'other', description: 'Other', parameters: {} }] } } })
+ expect(replacement.data.message.content).toEqual([{ type: 'tool-addition', toolName: 'search' }])
+ expect(foldSurface(events).nodes).toEqual([replacement.seq])
+ const restored = Session.fromRestore(session.id, events, session.header, SessionLogOffset(0), 'shared-frozen')
+ expect(restored.deriveMessages()).toEqual([replacement.data.message])
+ const childId = SessionId('developer-header-child')
+ const child = Session.create(childId, buildForkSeed(events, replacement.seq), {
+ ...session.header, id: childId, parentSession: session.id, isSeeded: true,
+ }, SessionLogOffset(replacement.seq + 1))
+ expect(child.snapshotEvents()[replacement.seq]).toEqual(replacement)
+ expect(child.snapshotEvents()[headerSeq]).toEqual(events[headerSeq])
+ })
+
+ it.each([0, 3, 100])('refuses a non-header or non-earlier reference %s before append', (reference) => {
+ const { session } = opened()
+ const before = session.snapshotEvents()
+ expect(() => session.append('developer/message', {
+ turn: 1, step: 1, headerSeq: SessionSeq(reference), message: addition(),
+ }, { surfaceOp: 'append' })).toThrow('earlier request/header')
+ expect(session.snapshotEvents()).toBe(before)
+ })
+
+ it.each([{ tools: [{ ...first, name: 'different' }] }, { tools: [first, second] }])('refuses missing or ambiguous historical tool names %#', ({ tools }) => {
+ const { session, headerSeq } = opened(tools)
+ expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: addition() }, { surfaceOp: 'append' }))
+ .toThrow('exactly one tool')
+ })
+
+ it('refuses an addition bound to a tool-less header', () => {
+ const session = Session.create(SessionId('without-tools'))
+ const headerSeq = session.append('request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' } } }).seq
+ expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: addition() }, { surfaceOp: 'append' }))
+ .toThrow('exactly one tool')
+ })
+
+ it('requires a header exactly for additions and rejects inline definitions', () => {
+ const { session, headerSeq } = opened()
+ expect(() => session.append('developer/message', { turn: 1, step: 1, message: addition() }, { surfaceOp: 'append' })).toThrow('requires headerSeq')
+ const removal = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-removal', toolName: 'search' }] })
+ expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: removal }, { surfaceOp: 'append' })).toThrow('requires headerSeq')
+ // @ts-expect-error -- legacy inline definitions are rejected at Session admission.
+ const inline = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-addition', toolName: 'search', tool: first }] })
+ expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: inline }, { surfaceOp: 'append' })).toThrow('omit inline')
+ const unnamed = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-addition', toolName: '' }] })
+ expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: unnamed }, { surfaceOp: 'append' })).toThrow('nonempty toolName')
+ })
+
+ it.each([
+ { name: 'search' },
+ { name: 'search', description: null, parameters: {} },
+ { name: 'search', description: '', parameters: [] },
+ { name: 'search', description: '', parameters: {}, deferLoading: false },
+ ])('refuses an incomplete referenced definition in imported history %#', (tool) => {
+ const { session, headerSeq } = opened()
+ const source = session.snapshotEvents()[headerSeq]!
+ const events = JSON.parse(JSON.stringify([
+ ...session.snapshotEvents().slice(0, headerSeq),
+ { ...source, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [tool] } } },
+ { type: 'developer/message', seq: 3, time: 4, surfaceOp: 'append', data: { turn: 1, step: 1, headerSeq, message: addition() } },
+ ])) as SessionEvent[]
+ expect(() => Session.create(SessionId('incomplete-tool'), events)).toThrow(/complete tool definition|deferLoading/)
+ })
+
+ it('applies the same binding check to imported events and full-log surface folds', () => {
+ const { session } = opened()
+ const invalid: SessionEvent<'developer/message'> = {
+ type: 'developer/message', seq: SessionSeq(3), time: 4, surfaceOp: 'append',
+ data: { turn: 1, step: 1, headerSeq: SessionSeq(0), message: addition() },
+ }
+ const events = [...session.snapshotEvents(), invalid]
+ expect(() => Session.create(SessionId('bad-import'), events)).toThrow('earlier request/header')
+ expect(() => foldSurface(events)).toThrow('earlier request/header')
+ })
+})
diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts
index 6a13685e378b..2e97fde546d2 100644
--- a/packages/core/session/tests/fork.spec.ts
+++ b/packages/core/session/tests/fork.spec.ts
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@mutantcat/cordis'
-import { createUserMessage, ToolCallId , createMessage } from '@mutantcat/dsh-llm'
-import SessionStore, { Session, SessionForkError, SessionId, SessionLogOffset, SessionSeq } from '@mutantcat/dsh-session'
-import type { SessionEvent, TurnEndReason } from '@mutantcat/dsh-session'
+import { createUserMessage, ToolCallId, createMessage } from '@mutantcat/dsh-llm'
+import type { MessageSource } from '@mutantcat/dsh-llm'
+import SessionStore, { Session, SessionForkError, SessionId, SessionLogOffset, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@mutantcat/dsh-session'
+import type { SessionEvent, SurfaceEvent, TurnEndReason } from '@mutantcat/dsh-session'
declare module '@mutantcat/dsh-session/types' {
interface SessionEventMap {
@@ -85,7 +86,8 @@ describe('SessionStore.fork', () => {
expect(child.snapshotEvents()).not.toBe(source.snapshotEvents())
expect(child.snapshotEvents()[1]).not.toBe(source.snapshotEvents()[1])
expect(() => {
- firstUserMessage(child.snapshotEvents()).data.content[0] = { type: 'text', text: 'child mutation' }
+ (firstUserMessage(child.snapshotEvents()).data as unknown as { content: { type: string; text?: string }[] }).content[0]
+ = { type: 'text', text: 'child mutation' }
}).toThrow(TypeError)
expect(firstUserMessage(source.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }])
expect(firstUserMessage(child.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }])
@@ -172,7 +174,7 @@ describe('SessionStore.fork', () => {
const boundary = child.snapshotEvents().at(-1)
expect(boundary).toMatchObject({ type: 'session/end-seed' })
expect(boundary!.seq).toBeGreaterThan(open.seq)
- expect(child.firstLiveSeq).toBe(open.seq + 1)
+ expect(child.firstLiveSeq).toBe(open.seq + 2)
expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } })
})
@@ -231,73 +233,6 @@ describe('SessionStore.fork', () => {
.toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))
})
- it('rejects selected slices whose boundary is inside an open turn', async () => {
- const { ctx, sessions } = await setup()
- const cases: [string, (session: Session) => number][] = [
- ['turn/start', (session) => {
- session.append('turn/start', { turn: 1 })
- return lastSeq(session)
- }],
- ['step/start', (session) => {
- session.append('turn/start', { turn: 1 })
- session.append('step/start', { turn: 1, step: 1 })
- return lastSeq(session)
- }],
- ['user/message', (session) => {
- session.append('turn/start', { turn: 1 })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'open' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- return lastSeq(session)
- }],
- ['assistant/message', (session) => {
- session.append('turn/start', { turn: 1 })
- session.append('step/start', { turn: 1, step: 1 })
- session.append('assistant/message', {
- stream: [],
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'partial' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: 'append' })
- return lastSeq(session)
- }],
- ['tool/call', (session) => {
- const callId = ToolCallId('call-open')
- session.append('turn/start', { turn: 1 })
- session.append('step/start', { turn: 1, step: 1 })
- session.append('assistant/message', {
- stream: [],
- turn: 1,
- step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: 'append' })
- session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
- return lastSeq(session)
- }],
- ]
-
- for (const [lastType, build] of cases) {
- const source = ctx.sessions.create(SessionId(`open-${lastType}`))
- const boundary = build(source)
-
- expect(() => sessions.fork(source, SessionSeq(boundary)))
- .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" ends inside open turn 1`, 'OPEN_TURN'))
- }
- })
-
it('rejects a child session id that is already live with a typed fork error', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'))
@@ -318,3 +253,220 @@ describe('SessionStore.fork', () => {
.toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
})
})
+
+describe('fork boundaries inside an open turn', () => {
+ /** Open turn 2 after a closed turn 1 and return the source. */
+ async function openTurnSource(id: string): Promise<{ ctx: Context; sessions: SessionStore; source: Session }> {
+ const { ctx, sessions } = await setup()
+ const source = ctx.sessions.create(SessionId(id))
+ appendClosedTurn(source, 1, 'first')
+ source.append('turn/start', { turn: 2 })
+ return { ctx, sessions, source }
+ }
+
+ /** Append the open step + assistant tool request of turn 2. */
+ function appendToolRequest(session: Session, callId: ToolCallId): void {
+ session.append('step/start', { turn: 2, step: 1 })
+ session.append('assistant/message', {
+ stream: [],
+ turn: 2, step: 1,
+ message: createMessage({
+ role: 'assistant',
+ content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
+ source: {
+ kind: 'model',
+ ...{ provider: 'mock', model: 'mock' },
+ },
+ }),
+ }, { surfaceOp: 'append' })
+ }
+
+ it('closes an empty open turn with a synthetic turn/end {forked} outside seedLength', async () => {
+ const { sessions, source } = await openTurnSource('open-empty')
+
+ const child = sessions.fork(source, lastSeq(source), SessionId('open-empty-child'))
+
+ const seed = child.snapshotEvents()
+ expect(seed.slice(0, source.snapshotEvents().length)).toEqual(source.snapshotEvents())
+ expect(seed.at(-1)).toMatchObject({
+ type: 'turn/end',
+ data: { turn: 2, reason: { kind: 'forked' } },
+ })
+ // The closer is child work: `seedLength` names only events that exist in
+ // the parent's log, so telemetry receivers stitching a fork's prefix from
+ // the parent's stream on `(parent_id, seed_length)` read exactly the
+ // copied prefix.
+ expect(child.inheritedEventCount).toBe(source.snapshotEvents().length)
+ })
+
+ it('closes only the turn when the cut lands on a closed step boundary', async () => {
+ const { sessions, source } = await openTurnSource('open-step-closed')
+ source.append('step/start', { turn: 2, step: 1 })
+ source.append('assistant/message', {
+ stream: [],
+ turn: 2, step: 1,
+ message: createMessage({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'step done' }],
+ source: {
+ kind: 'model',
+ ...{ provider: 'mock', model: 'mock' },
+ },
+ }),
+ }, { surfaceOp: 'append' })
+ source.append('step/end', { turn: 2, step: 1 })
+
+ const child = sessions.fork(source, lastSeq(source), SessionId('open-step-closed-child'))
+
+ expect(child.snapshotEvents().slice(source.snapshotEvents().length + 1).map(e => e.type)).toEqual(['turn/end'])
+ })
+
+ it('answers an assistant call cut before its tool/call with a forked not-started error result', async () => {
+ const { sessions, source } = await openTurnSource('open-not-started')
+ appendToolRequest(source, ToolCallId('call-ns'))
+
+ const child = sessions.fork(source, lastSeq(source), SessionId('open-not-started-child'))
+
+ const closers = child.snapshotEvents().slice(source.snapshotEvents().length + 1)
+ expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
+ const result = closers[0]!
+ expect(result.type === 'tool/result' && result.data).toMatchObject({
+ message: {
+ id: expect.stringMatching(/^forked-tool-result-call-ns-/) as unknown,
+ source: { callId: ToolCallId('call-ns') },
+ },
+ error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
+ })
+ // The synthetic result is part of the child's model-visible history, not a
+ // request-time patch: the derived messages end with the error tool result.
+ const derived = child.deriveMessages()
+ expect(derived.at(-1)).toMatchObject({
+ role: 'tool',
+ source: { kind: 'tool', callId: ToolCallId('call-ns') },
+ toolCallId: ToolCallId('call-ns'),
+ isError: true,
+ content: [{ type: 'text' }],
+ })
+ })
+
+ it('answers a dispatched call cut before its result with an unknown-outcome error citing the tool/call', async () => {
+ const { sessions, source } = await openTurnSource('open-unknown')
+ appendToolRequest(source, ToolCallId('call-uo'))
+ const call = source.append('tool/call', { turn: 2, step: 1, callId: ToolCallId('call-uo'), name: 'bash', arguments: '{}' })
+
+ const child = sessions.fork(source, lastSeq(source), SessionId('open-unknown-child'))
+
+ const closers = child.snapshotEvents().slice(source.snapshotEvents().length + 1)
+ expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
+ const result = closers[0]!
+ expect(result.type === 'tool/result' && result.data.error).toEqual({
+ name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
+ })
+ expect((result as SurfaceEvent).sourceEventSeqs).toEqual([call.seq])
+ })
+
+ it('omits a failed assistant attempt from derived history', async () => {
+ const { sessions, source } = await openTurnSource('open-mid-stream')
+ source.append('step/start', { turn: 2, step: 1 })
+ source.append('assistant/attempt', {
+ turn: 2, step: 1, stream: [],
+ })
+
+ const child = sessions.fork(source, lastSeq(source), SessionId('open-mid-stream-child'))
+
+ expect(child.snapshotEvents().slice(source.snapshotEvents().length + 1).map(e => e.type)).toEqual(['step/end', 'turn/end'])
+ expect(child.deriveMessages().some(message => message.role === 'assistant')).toBe(false)
+ })
+
+ it.each([false, true])('preserves a closed step with an unanswered call (turn ended: %s)', async (turnEnded) => {
+ const { sessions, source } = await openTurnSource('dangling-closed-step')
+ appendToolRequest(source, ToolCallId('call-dangling'))
+ source.append('tool/call', {
+ turn: 2, step: 1, callId: ToolCallId('call-dangling'), name: 'bash', arguments: '{}',
+ })
+ source.append('step/end', { turn: 2, step: 1 })
+ if (turnEnded) {
+ source.append('turn/end', {
+ turn: 2, reason: { kind: 'error', error: { message: 'scheduler failed', code: 'UNKNOWN' } },
+ })
+ }
+ const copiedPrefix = source.snapshotEvents()
+
+ const child = sessions.fork(source)
+
+ expect(inherited(child).slice(0, copiedPrefix.length)).toEqual(copiedPrefix)
+ expect(child.snapshotEvents().slice(copiedPrefix.length + 1)).toEqual(turnEnded ? [] : [{
+ type: 'turn/end', seq: SessionSeq(copiedPrefix.length + 1), time: copiedPrefix.at(-1)!.time,
+ data: { turn: 2, reason: { kind: 'forked' } },
+ }])
+ expect(child.inheritedEventCount).toBe(copiedPrefix.length)
+ expect(child.deriveMessages()).toEqual(source.deriveMessages())
+ })
+
+ it('leaves a plugin bracket left open at the cut untouched (log-only locks belong to their owner)', async () => {
+ const { sessions, source } = await openTurnSource('open-bracket')
+ source.append('test/bracket-open', { id: 'op-live' })
+
+ const child = sessions.fork(source, lastSeq(source), SessionId('open-bracket-child'))
+
+ const closers = child.snapshotEvents().slice(source.snapshotEvents().length + 1)
+ expect(closers.map(e => e.type)).toEqual(['turn/end'])
+ // The unmatched bracket sits before the child's end-seed marker, so its
+ // owning plugin decides its staleness from that boundary.
+ expect(child.snapshotEvents()[child.inheritedEventCount]).toMatchObject({ type: 'session/end-seed', data: { inherited: true } })
+ })
+})
+
+describe('fork boundaries around a surface replacement', () => {
+ /**
+ * A source whose open turn 2 replaces turn 1's user message with a summary
+ * (the compaction surface pattern: a `user/message` with a range replace op).
+ */
+ async function replacementSource(id: string): Promise<{
+ sessions: SessionStore
+ source: Session
+ replacedSeq: SessionSeq
+ replacementSeq: SessionSeq
+ }> {
+ const { ctx, sessions } = await setup()
+ const source = ctx.sessions.create(SessionId(id))
+ appendClosedTurn(source, 1, 'original request')
+ const replacedSeq = firstUserMessage(source.snapshotEvents()).seq
+ source.append('turn/start', { turn: 2 })
+ const replacement = source.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'summary of earlier work' }],
+ source: {
+ kind: 'compact-checkpoint',
+ compactionId: 'open-bracket' as Extract['compactionId'],
+ },
+ }), {
+ surfaceOp: { op: 'replace', startSeq: replacedSeq, endSeq: replacedSeq },
+ sourceEventSeqs: [replacedSeq],
+ })
+ return { sessions, source, replacedSeq, replacementSeq: replacement.seq }
+ }
+
+ it('derives the original message for a cut before the replacement', async () => {
+ const { sessions, source, replacementSeq } = await replacementSource('replace-before')
+
+ const child = sessions.fork(source, SessionSeq(replacementSeq - 1), SessionId('replace-before-child'))
+
+ expect(child.deriveMessages().map(message => message.content)).toEqual([
+ [{ type: 'text', text: 'original request' }],
+ ])
+ })
+
+ it('derives the compacted surface for a cut after the replacement', async () => {
+ const { sessions, source, replacementSeq } = await replacementSource('replace-after')
+
+ const child = sessions.fork(source, replacementSeq, SessionId('replace-after-child'))
+
+ expect(child.deriveMessages().map(message => message.content)).toEqual([
+ [{ type: 'text', text: 'summary of earlier work' }],
+ ])
+ expect(child.snapshotEvents().at(-1)).toMatchObject({
+ type: 'turn/end',
+ data: { turn: 2, reason: { kind: 'forked' } },
+ })
+ })
+})
diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts
index 78417aab4bd7..798d102fc0c4 100644
--- a/packages/core/session/tests/invariant.spec.ts
+++ b/packages/core/session/tests/invariant.spec.ts
@@ -1,11 +1,18 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@mutantcat/cordis'
import { createScope, scopeTarget } from '@mutantcat/dsh-scope'
-import { createSystemMessage, createUserMessage, ToolCallId, createMessage, createToolResultMessage, freezeMessage } from '@mutantcat/dsh-llm'
+import { createDeveloperMessage, createSystemMessage, createUserMessage, ToolCallId, createMessage, createToolResultMessage, freezeMessage } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { SessionId, SessionSeq, TOOL_NOT_STARTED } from '@mutantcat/dsh-session'
import * as SessionInvariant from '@mutantcat/dsh-session/invariant'
import InvariantRegistry, { InvariantError } from '@mutantcat/dsh-invariants'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
async function setup(): Promise<{ ctx: Context; fiber: Awaited> }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -31,6 +38,27 @@ describe('session-log invariants', () => {
}).not.toThrow()
})
+ it('requires developer messages to belong to the open turn and step', async () => {
+ const { ctx } = await setup()
+ try {
+ const session = ctx.sessions.create()
+ const message = createDeveloperMessage({ content: [{ type: 'tool-removal', toolName: 'search' }], source: { kind: 'test' } })
+ const append = (turn: number, step: number) => session.append('developer/message', { turn, step, message }, { surfaceOp: 'append' })
+ expect(() => append(1, 1)).toThrow('developer/message names turn 1/step 1')
+ session.append('turn/start', { turn: 1 })
+ expect(() => append(1, 1)).toThrow('developer/message names turn 1/step 1')
+ session.append('step/start', { turn: 1, step: 1 })
+ expect(() => append(2, 1)).toThrow('developer/message names turn 2/step 1')
+ expect(() => append(1, 2)).toThrow('developer/message names turn 1/step 2')
+ expect(() => append(1, 1)).not.toThrow()
+ expect(session.deriveMessages()).toEqual([message])
+ session.append('step/end', { turn: 1, step: 1 })
+ expect(() => append(1, 1)).toThrow('developer/message names turn 1/step 1')
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
it('accepts a well-formed turn, step, and tool sequence', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
@@ -156,7 +184,7 @@ describe('session-log invariants', () => {
const outside = (await setup()).ctx.sessions.create()
expect(() => outside.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}), { surfaceOp: 'append' })).not.toThrow()
// Route capacity is core execution state like the header beside it.
expect(() => outside.append('request/context', {
@@ -237,7 +265,7 @@ describe('session-log invariants', () => {
it('requires a system/message to name the open step', async () => {
const session = (await setup()).ctx.sessions.create()
session.append('turn/start', { turn: 1 })
- const message = createSystemMessage('You are terse.', '@mutantcat/dsh-system-prompt')
+ const message = createSystemMessage('You are terse.')
expect(() => session.append('system/message', { turn: 1, step: 1, message }, { surfaceOp: 'append' }))
.toThrow(/open is turn 1\/step null/)
session.append('step/start', { turn: 1, step: 1 })
@@ -288,10 +316,7 @@ describe('session-log invariants', () => {
...original.data,
message: freezeMessage({
...original.data.message,
- content: [{
- ...original.data.message.content[0],
- content: [{ type: 'text', text: 'pruned' }],
- }] satisfies typeof original.data.message.content,
+ content: [{ type: 'text', text: 'pruned' }],
}),
}, {
surfaceOp: { op: 'replace', startSeq: original.seq, endSeq: original.seq },
@@ -327,10 +352,7 @@ describe('session-log invariants', () => {
...original.data,
message: freezeMessage({
...original.data.message,
- content: [{
- ...original.data.message.content[0],
- content: [{ type: 'text', text: 'pruned' }],
- }] satisfies typeof original.data.message.content,
+ content: [{ type: 'text', text: 'pruned' }],
}),
}, {
surfaceOp: { op: 'replace', startSeq: original.seq, endSeq: original.seq },
diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts
index 3a6266395c57..2cdfe2c38d32 100644
--- a/packages/core/session/tests/properties.spec.ts
+++ b/packages/core/session/tests/properties.spec.ts
@@ -164,11 +164,11 @@ describe('Session properties', () => {
const messages = session.deriveMessages()
const before = structuredClone(session.snapshotEvents())
for (const m of messages) {
- expect(['user', 'assistant', 'system']).toContain(m.role)
+ expect(['user', 'assistant', 'system', 'tool']).toContain(m.role)
// Derived messages are frozen shared projections: mutation THROWS
// (strict mode) instead of relying on per-call clones for isolation.
expect(Object.isFrozen(m)).toBe(true)
- expect(() => { m.content.push({ type: 'text', text: 'mutation' }) }).toThrow(TypeError)
+ expect(() => { (m.content as unknown as { type: string; text?: string }[]).push({ type: 'text', text: 'mutation' }) }).toThrow(TypeError)
}
expect(session.snapshotEvents()).toEqual(before)
}))
diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts
index 19c65de4049c..96e3748f3507 100644
--- a/packages/core/session/tests/repair.spec.ts
+++ b/packages/core/session/tests/repair.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { ToolCallId , createMessage, createToolResultMessage } from '@mutantcat/dsh-llm'
import { interruptedTurnClosers as repairInterruptedTurn, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
+import { openTurnClosers as closeOpenTurn, type OpenTurnCloseCause } from '../src/repair.ts'
import type { SessionEvent as LogicalSessionEvent, SurfaceEvent } from '../src/index.ts'
interface SessionEvent {
@@ -16,6 +17,11 @@ function interruptedTurnClosers(events: readonly SessionEvent[]): LogicalSession
return repairInterruptedTurn(events as unknown as readonly LogicalSessionEvent[])
}
+function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): LogicalSessionEvent[] {
+ for (const event of events) SessionSeq(event.seq)
+ return closeOpenTurn(events as unknown as readonly LogicalSessionEvent[], cause)
+}
+
/**
* Unit coverage for the crash-recovery closer synthesis. The persistence
* contract exercises it end-to-end through the real JSONL provider; these tests pin the
@@ -28,26 +34,28 @@ function interruptedTurnClosers(events: readonly SessionEvent[]): LogicalSession
const userTurnStart = (turn: number, seq: number): SessionEvent =>
({ type: 'turn/start', seq, time: seq, data: { turn } })
-describe('interruptedTurnClosers', () => {
+const causes: OpenTurnCloseCause[] = [{ kind: 'interrupted' }, { kind: 'forked' }]
+
+describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
it('returns nothing for a balanced log (ends on turn/end)', () => {
const balanced: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
]
- expect(interruptedTurnClosers(balanced)).toEqual([])
+ expect(openTurnClosers(balanced, cause)).toEqual([])
})
it('returns nothing for an empty log', () => {
- expect(interruptedTurnClosers([])).toEqual([])
+ expect(openTurnClosers([], cause)).toEqual([])
})
- it('closes an open turn with no open step (turn/end {interrupted} only)', () => {
+ it('closes an open turn with no open step (turn/end with the cause reason only)', () => {
const events: SessionEvent[] = [userTurnStart(1, 0)]
- const closers = interruptedTurnClosers(events)
+ const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['turn/end'])
const end = closers[0]!
expect(end.seq).toBe(1)
- expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
+ expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: cause.kind })
})
it('closes an open step before the turn (step/end then turn/end)', () => {
@@ -55,7 +63,7 @@ describe('interruptedTurnClosers', () => {
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
]
- const closers = interruptedTurnClosers(events)
+ const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
expect(closers.map(e => e.seq)).toEqual([2, 3])
})
@@ -79,7 +87,7 @@ describe('interruptedTurnClosers', () => {
}),
} },
]
- const closers = interruptedTurnClosers(events)
+ const closers = openTurnClosers(events, cause)
// tool/result (for the orphaned call) → step/end → turn/end, contiguous seqs.
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
@@ -88,13 +96,18 @@ describe('interruptedTurnClosers', () => {
turn: 2,
step: 1,
message: {
+ id: `${cause.kind}-tool-result-call-1-3`,
source: { callId: ToolCallId('call-1') },
- content: [{ isError: true }],
+ role: 'tool',
+ toolCallId: ToolCallId('call-1'),
+ isError: true,
},
error: { code: TOOL_NOT_STARTED },
})
- expect(result.type === 'tool/result' && result.data.message.content[0].content).toEqual([{
- type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
+ expect(result.type === 'tool/result' && result.data.message.content).toEqual([{
+ type: 'text', text: cause.kind === 'forked'
+ ? 'The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
+ : 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}])
})
@@ -125,11 +138,11 @@ describe('interruptedTurnClosers', () => {
} },
]
// The call is answered, so only the open step + turn need closing.
- const closers = interruptedTurnClosers(events)
+ const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
- it('does NOT synthesize a result after the owning step already closed', () => {
+ it('preserves an unanswered assistant call after the owning step already closed', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
@@ -149,14 +162,15 @@ describe('interruptedTurnClosers', () => {
{ type: 'step/end', seq: 3, time: 3, data: { turn: 2, step: 1 } },
]
- const closers = interruptedTurnClosers(events)
- expect(closers.map(e => e.type)).toEqual(['turn/end'])
- expect(closers[0]?.seq).toBe(4)
+ const closers = openTurnClosers(events, cause)
+ expect(closers).toEqual([{
+ type: 'turn/end', seq: 4, time: 3, data: { turn: 2, reason: cause },
+ }])
})
it('synthesizes results only for the still-open turn, not a committed earlier turn', () => {
- // Turn 1 completed with its own tool call+result (balanced). Turn 2 crashed
- // with an unanswered call. Only turn 2's call must get a synthetic result.
+ // Turn 1 completed with its own tool call+result (balanced). Turn 2 was cut
+ // open with an unanswered call. Only turn 2's call must get a synthetic result.
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
@@ -199,7 +213,7 @@ describe('interruptedTurnClosers', () => {
}),
} },
]
- const closers = interruptedTurnClosers(events)
+ const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('new-call')
@@ -223,7 +237,7 @@ describe('interruptedTurnClosers', () => {
},
}),
} },
- // call-a got answered before the crash; call-b did not.
+ // call-a got answered before the cut; call-b did not.
{ type: 'tool/result', seq: 3, time: 3, data: {
turn: 1, step: 1,
message: createToolResultMessage({
@@ -233,7 +247,7 @@ describe('interruptedTurnClosers', () => {
}),
} },
]
- const closers = interruptedTurnClosers(events)
+ const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('call-b')
@@ -258,7 +272,7 @@ describe('interruptedTurnClosers', () => {
} },
{ type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: ToolCallId('call-1'), name: 'bash', arguments: '{}' } },
]
- const closers = interruptedTurnClosers(events)
+ const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect((result as SurfaceEvent).surfaceOp).toBe('append')
@@ -266,11 +280,11 @@ describe('interruptedTurnClosers', () => {
expect(result.type === 'tool/result' && result.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
- if (result.type !== 'tool/result' || result.data.message.content[0].content[0]?.type !== 'text') {
+ if (result.type !== 'tool/result' || result.data.message.content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
- expect(result.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
- expect(result.data.message.content[0].content[0].text).toContain('first verify external state or ask the user')
+ expect(result.data.message.content[0].text).toContain('retry only if the operation is read-only or idempotent')
+ expect(result.data.message.content[0].text).toContain('first verify external state or ask the user')
})
it('handles tool/call without a matching assistant/message entry gracefully', () => {
@@ -281,8 +295,89 @@ describe('interruptedTurnClosers', () => {
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'tool/call', seq: 2, time: 2, data: { turn: 1, step: 1, callId: ToolCallId('orphan'), name: 'bash', arguments: '{}' } },
]
- const closers = interruptedTurnClosers(events)
+ const closers = openTurnClosers(events, cause)
// No pending calls → no synthetic tool/result, just step/end + turn/end.
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
})
+
+describe('openTurnClosers model-visible wording', () => {
+ /** An open turn with one unanswered call; `started` also logs its tool/call. */
+ function openCall(started: boolean): SessionEvent[] {
+ const events: SessionEvent[] = [
+ userTurnStart(1, 0),
+ { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
+ { type: 'assistant/message', seq: 2, time: 2, data: {
+ turn: 1, step: 1,
+ message: createMessage({
+ role: 'assistant',
+ content: [
+ { type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{}' },
+ ],
+ source: {
+ kind: 'model',
+ ...{ provider: 'mock', model: 'mock' },
+ },
+ }),
+ } },
+ ]
+ if (started) {
+ events.push({ type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: ToolCallId('call-1'), name: 'bash', arguments: '{}' } })
+ }
+ return events
+ }
+
+ function resultText(closers: LogicalSessionEvent[]): string {
+ const result = closers[0]
+ if (result?.type !== 'tool/result' || result.data.message.content[0]?.type !== 'text') {
+ throw new Error('expected a text tool result')
+ }
+ return result.data.message.content[0].text
+ }
+
+ // The exact wording is model-visible: pinned verbatim per cause and lifecycle.
+ it.each([
+ [
+ { kind: 'interrupted' } satisfies OpenTurnCloseCause,
+ false,
+ 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
+ ],
+ [
+ { kind: 'interrupted' } satisfies OpenTurnCloseCause,
+ true,
+ 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
+ ],
+ [
+ { kind: 'forked' } satisfies OpenTurnCloseCause,
+ false,
+ 'The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
+ ],
+ [
+ { kind: 'forked' } satisfies OpenTurnCloseCause,
+ true,
+ 'The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
+ ],
+ ])('cause %o, started=%s pins its exact text', (cause, started, text) => {
+ expect(resultText(openTurnClosers(openCall(started), cause))).toBe(text)
+ })
+
+ it('shares the error codes across causes: they state the same call lifecycle fact', () => {
+ for (const cause of causes) {
+ const notStarted = openTurnClosers(openCall(false), cause)[0]
+ expect(notStarted?.type === 'tool/result' && notStarted.data.error).toEqual({
+ name: 'ToolNotStartedError', code: TOOL_NOT_STARTED,
+ })
+ const started = openTurnClosers(openCall(true), cause)[0]
+ expect(started?.type === 'tool/result' && started.data.error).toEqual({
+ name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
+ })
+ }
+ })
+
+ it('interruptedTurnClosers is the public interrupted-cause entry (cause selection stays internal)', () => {
+ const events = openCall(true)
+ expect(interruptedTurnClosers(events)).toEqual(openTurnClosers(events, { kind: 'interrupted' }))
+ const end = interruptedTurnClosers(events).at(-1)
+ expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
+ })
+})
diff --git a/packages/core/session/tests/sequence-types.spec.ts b/packages/core/session/tests/sequence-types.spec.ts
index f0891ad597af..9559e8216f26 100644
--- a/packages/core/session/tests/sequence-types.spec.ts
+++ b/packages/core/session/tests/sequence-types.spec.ts
@@ -107,6 +107,23 @@ describe('Session log positions', () => {
expect(child.isOwnSeq(SessionSeq(3))).toBe(true)
})
+ it('accepts child closers after the inherited marker and rejects a later inherited marker', () => {
+ const id = SessionId('marked-fork')
+ const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: true }
+ const seed = [
+ { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
+ { type: 'session/end-seed', seq: SessionSeq(1), time: 2, data: { inherited: true } },
+ { type: 'turn/end', seq: SessionSeq(2), time: 3, data: { turn: 1, reason: { kind: 'forked' } } },
+ ] as const
+ const child = Session.create(id, seed, header, SessionLogOffset(1))
+ expect(child.snapshotEvents()).toEqual(seed)
+ expect(child.ownEvents().map(event => event.type)).toEqual(['session/end-seed', 'turn/end'])
+ expect(child.firstLiveSeq).toBe(3)
+ expect(() => Session.create(id, [...seed, {
+ type: 'session/end-seed', seq: SessionSeq(3), time: 4, data: { inherited: true },
+ }], header, SessionLogOffset(1))).toThrow('final inherited marker')
+ })
+
it('requires a separately supplied inherited cut for a seeded header', () => {
const id = SessionId('missing-cut')
expect(() => Session.create(id, [], {
diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts
index 66682eba3ce8..b79286813f8b 100644
--- a/packages/core/session/tests/session.spec.ts
+++ b/packages/core/session/tests/session.spec.ts
@@ -1,6 +1,8 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from '@mutantcat/cordis'
-import { createSystemMessage, createUserMessage, ToolCallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@mutantcat/dsh-llm'
+import { createDeveloperMessage, createSystemMessage, createUserMessage, ToolCallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@mutantcat/dsh-llm'
+import type { ContentBlock } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, {
adoptSessionEvent,
SESSION_FORMAT_VERSION,
@@ -13,6 +15,13 @@ import SessionStore, {
} from '@mutantcat/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface } from '@mutantcat/dsh-session'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ 'watcher': { kind: 'watcher' } & ContextFormed
+ }
+}
+
describe('Session', () => {
it('exposes one stable readonly surface view', () => {
const session = Session.create(SessionId('surface-view'))
@@ -58,10 +67,10 @@ describe('Session', () => {
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const messages = session.deriveMessages()
- expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
+ expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'tool'])
// raw chunks must NOT appear in derived history
expect(messages[1]!.content).toHaveLength(2)
- expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: ToolCallId('c1') })
+ expect(messages[2]).toMatchObject({ role: 'tool', content: [{ type: 'text', text: 'ok' }] })
})
it('accepts and round-trips a max-tokens turn/end reason', () => {
@@ -92,7 +101,7 @@ describe('Session', () => {
const session = Session.create(SessionId('s2'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed: a.ts' }],
- source: { kind: 'plugin', plugin: 'watcher' },
+ source: { kind: 'watcher' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'focus on tests' }],
@@ -110,13 +119,14 @@ describe('Session', () => {
const session = Session.create(SessionId('s2-raw'))
const message = createUserMessage({
content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }],
- source: { kind: 'plugin', plugin: 'agent-instructions' },
+ source: { kind: 'agent-instructions', form: 'instructions', changes: [] },
})
session.append('user/message', message, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual([message])
const event = session.snapshotEvents()[0]
- expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'agent-instructions' })
+ expect(event?.type === 'user/message' && event.data.source)
+ .toEqual({ kind: 'agent-instructions', form: 'instructions', changes: [] })
})
it('replays identically from a seeded event log', () => {
@@ -290,12 +300,9 @@ describe('Session', () => {
}
const tool = {
id: 'tool',
- role: 'user',
- content: [{
- type: 'tool-result',
- toolCallId: 'call',
- content: [{ type: 'text', text: 'result' }],
- }],
+ role: 'tool',
+ toolCallId: 'call',
+ content: [{ type: 'text', text: 'result' }],
source: { kind: 'tool', callId: 'call' },
}
const invalid = [
@@ -362,7 +369,7 @@ describe('Session', () => {
data: {
turn: 1,
step: 1,
- message: { ...user, id: 'system', source: { kind: 'plugin', plugin: 'prompt' } },
+ message: { ...user, id: 'system', source: { kind: 'system-prompt' } },
},
},
message: 'message must have role "system"',
@@ -374,22 +381,22 @@ describe('Session', () => {
data: {
turn: 1,
step: 1,
- message: { ...user, id: 'system', role: 'system', source: { kind: 'plugin', plugin: '' } },
+ message: { ...user, id: 'system', role: 'system', source: { kind: 'runtime-context' } },
},
},
- message: 'message must have plugin source',
+ message: 'message must have system-prompt source',
},
{
- name: 'tool tuple',
+ name: 'tool role',
event: {
type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
data: {
turn: 1,
step: 1,
- message: { ...tool, content: [{ type: 'text', text: 'not a result' }] },
+ message: { ...tool, role: 'user' },
},
},
- message: 'message must contain one tool-result block',
+ message: 'message must have role "tool"',
},
{
name: 'tool correlation',
@@ -458,7 +465,7 @@ describe('Session', () => {
content: [{ type: 'text', text: 'owned' }],
source: { kind: 'user' },
},
- } as SessionEvent<'user/message'>
+ } as unknown as SessionEvent<'user/message'>
expect(adoptSessionEvent(owned)).toBe(owned)
expect(Object.isFrozen(owned.data)).toBe(true)
expect(Object.isFrozen(owned.data.content)).toBe(true)
@@ -470,6 +477,19 @@ describe('Session', () => {
expect(snapshot.data.content).not.toBe(source.data.content)
})
+ it('adopts developer messages in place and freezes their nested tool changes', () => {
+ const event: SessionEvent<'developer/message'> = {
+ type: 'developer/message', seq: SessionSeq(1), time: 1, surfaceOp: 'append',
+ data: { turn: 1, step: 1, headerSeq: SessionSeq(0), message: structuredClone(createDeveloperMessage({
+ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'test' },
+ })) },
+ }
+ expect(Object.isFrozen(event.data.message)).toBe(false)
+ expect(adoptSessionEvent(event)).toBe(event)
+ expect(Object.isFrozen(event.data.message)).toBe(true)
+ expect(Object.isFrozen(event.data.message.content[0])).toBe(true)
+ })
+
it('adopts a system/message by freezing its message', () => {
const event = {
type: 'system/message',
@@ -479,7 +499,7 @@ describe('Session', () => {
data: {
turn: 1,
step: 1,
- message: createSystemMessage('You are terse.', '@mutantcat/dsh-system-prompt'),
+ message: createSystemMessage('You are terse.'),
},
} as unknown as SessionEvent
const adopted = adoptSessionEvent(event)
@@ -585,11 +605,11 @@ describe('Session', () => {
const messages = session.deriveMessages()
const userBlock = messages[0]!.content[0]!
expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError)
- const toolBlock = messages[1]!.content[0]!
+ const toolMessage = messages[1]!
expect(() => {
- if (toolBlock.type === 'tool-result') toolBlock.content.push({ type: 'text', text: 'injected' })
+ if (toolMessage.role === 'tool') (toolMessage.content as ContentBlock[]).push({ type: 'text', text: 'injected' })
}).toThrow(TypeError)
- expect(() => { messages[0]!.content.push({ type: 'text', text: 'extra' }) }).toThrow(TypeError)
+ expect(() => { (messages[0]!.content as ContentBlock[]).push({ type: 'text', text: 'extra' }) }).toThrow(TypeError)
// The returned ARRAY is the caller's own snapshot, though — reordering it
// is the caller's business and never reaches the cache or the log.
messages.reverse()
@@ -1623,7 +1643,7 @@ describe('SessionStore', () => {
expect(() => session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'replacement' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}), {
surfaceOp: { op: 'replace', startSeq: SessionSeq(2), endSeq: SessionSeq(2) },
sourceEventSeqs: [SessionSeq(2)],
diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts
index f3fa6c5985d5..e4e8b2ba2514 100644
--- a/packages/core/session/tests/surface.spec.ts
+++ b/packages/core/session/tests/surface.spec.ts
@@ -6,6 +6,7 @@ import {
SessionLogOffset,
SessionSeq,
foldSurface,
+ snapshotSessionEvent,
isAppendSurfaceEvent,
isReplacementSurfaceEvent,
isSurfaceEligibleType,
@@ -13,16 +14,31 @@ import {
} from '@mutantcat/dsh-session'
import { SurfaceManager } from '@mutantcat/dsh-session/surface'
import {
+ MessageId,
+ ToolCallId,
createMessage,
+ createDeveloperMessage,
createSystemMessage,
createToolResultMessage,
createUserMessage,
freezeMessage,
- ToolCallId,
- MessageId,
} from '@mutantcat/dsh-llm'
+import type { ContextFormed, MessageSource } from '@mutantcat/dsh-llm'
+
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'test': { kind: 'test' } & ContextFormed
+ 'watcher': { kind: 'watcher' } & ContextFormed
+ }
+}
type TestSurfaceOp = 'append' | { op: 'replace'; startSeq: number; endSeq: number }
+type CheckpointSource = Extract
+
+/** Build a typed checkpoint source for a surface replacement fixture. */
+function checkpointSource(compactionId: string): CheckpointSource {
+ return { kind: 'compact-checkpoint', compactionId: compactionId as CheckpointSource['compactionId'] }
+}
function surfaceOp(value: TestSurfaceOp): SurfaceEvent['surfaceOp'] {
return value === 'append'
@@ -33,7 +49,7 @@ function surfaceOp(value: TestSurfaceOp): SurfaceEvent['surfaceOp'] {
function replacementMessage(text: string) {
return createUserMessage({
content: [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})
}
@@ -204,7 +220,6 @@ describe('foldSurface tool-result rewrites', () => {
] as const)('rejects a replacement that changes the result block %s', (_field, patch) => {
const original = toolResultEvent(SessionSeq(0), 'original')
const data = original.data as Extract['data']
- const result = data.message.content[0]
const replacement = {
...original,
seq: SessionSeq(1),
@@ -213,7 +228,7 @@ describe('foldSurface tool-result rewrites', () => {
...data,
message: freezeMessage({
...data.message,
- content: [{ ...result, ...patch }] as [typeof result],
+ ...patch,
}),
},
surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }),
@@ -690,7 +705,7 @@ describe('deriveMessages with surface', () => {
it('injected-context and user messages appear on surface', () => {
const s = Session.create(SessionId('ctx'))
s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
+ content: [{ type: 'text', text: 'file changed' }], source: { kind: 'watcher' },
}), { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'focus' }],
@@ -712,7 +727,7 @@ describe('Session.append surface opts', () => {
'user/message',
createUserMessage({
content: [{ type: 'text', text: 'h' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}),
{ surfaceOp: 'append', sourceEventSeqs: sourceSeqs(0, 1) },
)
@@ -833,7 +848,7 @@ describe('surface type guards', () => {
it('splits surface events into append-origin and replacement by their marker', () => {
const s = surfaceSession()
s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' },
+ content: [{ type: 'text', text: 'checkpoint' }], source: checkpointSource('surface-compaction-1'),
}), { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 2 }), sourceEventSeqs: sourceSeqs(1, 2) })
const appended = s.snapshotEvents().find(e => e.type === 'user/message')!
const replacement = s.snapshotEvents().at(-1)!
@@ -881,7 +896,7 @@ describe('SurfaceManager.replaceGeneration', () => {
const nodes = s.surface.nodes
s.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
+ content: [{ type: 'text', text: 'summary' }], source: checkpointSource('surface-compaction-2'),
}), { surfaceOp: { op: 'replace', startSeq: nodes[0]!, endSeq: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(s.surface.replaceGeneration).toBe(1)
})
@@ -893,7 +908,7 @@ describe('system/message surface node', () => {
type: 'system/message',
seq: SessionSeq(seq),
time: seq,
- data: { turn: 1, step: 1, message: createSystemMessage(text, 'test-plugin') },
+ data: { turn: 1, step: 1, message: createSystemMessage(text) },
surfaceOp: surfaceOp(op),
...sources === undefined ? {} : { sourceEventSeqs: sourceSeqs(...sources) },
}
@@ -913,13 +928,13 @@ describe('system/message surface node', () => {
const s = Session.create(SessionId('sys'))
s.append('turn/start', { turn: 1 })
s.append('step/start', { turn: 1, step: 1 })
- s.append('system/message', { turn: 1, step: 1, message: createSystemMessage('be brief', 'p') }, { surfaceOp: 'append' })
+ s.append('system/message', { turn: 1, step: 1, message: createSystemMessage('be brief') }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
expect(s.deriveMessages().map(message => message.role)).toEqual(['system', 'user'])
expect(s.deriveMessages()[0]?.content).toEqual([{ type: 'text', text: 'be brief' }])
const head = s.surface.nodes[0] as SessionSeq
- s.append('system/message', { turn: 1, step: 1, message: createSystemMessage('', 'p') }, {
+ s.append('system/message', { turn: 1, step: 1, message: createSystemMessage('') }, {
surfaceOp: { op: 'replace', startSeq: head, endSeq: head },
sourceEventSeqs: [head],
})
@@ -955,11 +970,55 @@ describe('system/message surface node', () => {
expect(plainHead.nodes).toEqual(sourceSeqs(2))
})
- it('rejects a seeded system/message with a non-system role or non-plugin source', () => {
+ it('rejects a seeded system/message with a non-system role or non-system-prompt source', () => {
const good = systemEvent(0, 'v1')
const badRole = { ...good, data: { ...good.data, message: { ...(good.data as { message: object }).message, role: 'user' } } }
expect(() => Session.create(SessionId('bad-role'), [badRole as SessionEvent])).toThrow(/role "system"/)
const badSource = { ...good, data: { ...good.data, message: { ...(good.data as { message: object }).message, source: { kind: 'user' } } } }
- expect(() => Session.create(SessionId('bad-source'), [badSource as SessionEvent])).toThrow(/plugin source/)
+ expect(() => Session.create(SessionId('bad-source'), [badSource as SessionEvent])).toThrow(/system-prompt source/)
+ })
+})
+
+
+describe('developer message history', () => {
+ it.each(['user/message', 'developer/message'] as const)('rejects a mismatched developer role in %s at event admission', (type) => {
+ const message = createDeveloperMessage({ source: { kind: 'test' }, content: [] })
+ const event = {
+ type, seq: SessionSeq(0), time: 0, surfaceOp: 'append',
+ data: type === 'user/message' ? message : { turn: 1, step: 1, message: { ...message, role: 'user' } },
+ } as unknown as SessionEvent
+ expect(() => snapshotSessionEvent(event)).toThrow('must occur together')
+ })
+
+ it('retains an empty developer node while projecting no message', () => {
+ const session = Session.create(SessionId('empty-developer'))
+ const message = createDeveloperMessage({ source: { kind: 'test' }, content: [] })
+ const event = session.append('developer/message', { turn: 1, step: 1, message }, { surfaceOp: 'append' })
+ expect(session.snapshotEvents()).toContain(event)
+ expect(session.deriveMessages()).toEqual([])
+ const restored = Session.fromRestore(session.id, session.snapshotEvents(), session.header, SessionLogOffset(0), 'shared-frozen')
+ expect(restored.deriveMessages()).toEqual([])
+ })
+
+ it.each(['tool-addition', 'tool-removal'] as const)('rejects %s in user messages at Session admission', (type) => {
+ const session = Session.create(SessionId('invalid-tool-change'))
+ const message = createUserMessage({ source: { kind: 'test' }, content: [{ type, toolName: 'search' }] })
+ expect(() => session.append('user/message', message, { surfaceOp: 'append' })).toThrow('developer role')
+ })
+
+ it('retains developer blocks through append, replacement, and restoration', () => {
+ const session = Session.create(SessionId('developer'))
+ const message = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-addition', toolName: 'search' }] })
+ const headerSeq = session.append('request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [{ name: 'search', description: 'Search', parameters: {} }] } }).seq
+ const first = session.append('developer/message', { turn: 1, step: 1, headerSeq, message }, { surfaceOp: 'append' })
+ expect(session.deriveMessages()).toEqual([message])
+ const replacement = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-removal', toolName: 'search' }] })
+ session.append('developer/message', { turn: 1, step: 1, message: replacement }, {
+ surfaceOp: { op: 'replace', startSeq: first.seq, endSeq: first.seq }, sourceEventSeqs: [first.seq],
+ })
+ expect(session.deriveMessages()).toEqual([replacement])
+ expect(isSurfaceEligibleType('developer/message')).toBe(true)
+ const restored = Session.fromRestore(session.id, session.snapshotEvents(), session.header, SessionLogOffset(0), 'shared-frozen')
+ expect(restored.deriveMessages()).toEqual([replacement])
})
})
diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json
index c54e71769be6..c058091af081 100644
--- a/packages/core/system-prompt/package.json
+++ b/packages/core/system-prompt/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-system-prompt",
"description": "System prompt assembly registry for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts
index 3c284acc4884..55c818ef2803 100644
--- a/packages/core/system-prompt/src/index.ts
+++ b/packages/core/system-prompt/src/index.ts
@@ -584,10 +584,11 @@ export class SystemPrompt extends Service {
const knownNames = new Set()
for (const provider of providers) {
const result = provider(context)
- const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({
+ const schemas = result.schemas.map(({ name, description, parameters, deferLoading }): ToolSchema => ({
name,
description,
parameters: structuredClone(parameters),
+ ...deferLoading === true ? { deferLoading } : {},
}))
const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name)
collected.push(...schemas)
diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml
index dc029dda9fbc..2717b1fd3959 100644
--- a/packages/core/tools/README.i18n.yaml
+++ b/packages/core/tools/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
-README.md: 356bbe4e44bbf597b7c59b3baeae0663af707e64
-README.zh.md: ee287b4dc8a34262cd665eef85b10d60dff4183a
+README.md: 30b7cf8f2ac40e3dca765491a8d7b9e48c58b5ec
+README.zh.md: c425a9aa9e096e3bcda2d23155912af7cc4a820d
diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md
index 356bbe4e44bb..30b7cf8f2ac4 100644
--- a/packages/core/tools/README.md
+++ b/packages/core/tools/README.md
@@ -126,6 +126,8 @@ Under `ptc` or `both`, the registry exposes the reserved `run_code` transport pl
New sub-calls use `:ptc:` ids. Consumers treat these ids as opaque and correlate events by exact equality; restored historical ids retain their original bytes. The [PTC mode decision](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) owns durable naming and restoration rules.
+Successful image-bearing subcall results become deferred user-message context with `source.kind` set to `ptc-mode`. Other additional contexts retain their producing tool's attribution.
+
`run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, the runtime's usage instructions and the Session working directory. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program.
@@ -230,6 +232,8 @@ These limits define when the registry needs special care. They are current packa
- **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap.
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP, because cross-call state would be invisible to the log.
+`defineTool()`, registry schema projection, and system-prompt assembly preserve `deferLoading: true`. The marker requests deferred definition loading and does not imply a `tool-addition` record; the [LLM package](../../../packages/llm/llm/README.md#known-limitations-and-deferred-work) documents provider enforcement limits.
+
### Dev Note
diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md
index ee287b4dc8a3..c425a9aa9e09 100644
--- a/packages/core/tools/README.zh.md
+++ b/packages/core/tools/README.zh.md
@@ -126,6 +126,8 @@ ctx.tools.register(defineTool({
新子调用使用 `:ptc:` 标识。消费方将这些标识视为不透明值,并通过精确相等关联事件;恢复的历史标识保留原始字节。[PTC mode 决策](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md) 负责持久化命名与恢复规则。
+成功且包含图片的子调用结果会成为延后的 user-message 上下文,其 `source.kind` 为 `ptc-mode`。其他 additional context 保留其生产工具的归属。
+
当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限、运行时使用说明及 Session 工作目录。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。
@@ -230,6 +232,8 @@ Program-only SDK bindings:
- **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。
- **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核,因为跨调用状态不会出现在日志中。
+`defineTool()`、注册表模式投影和系统提示组装会保留 `deferLoading: true`。该标记请求延迟加载工具定义,并不意味着存在 `tool-addition` 记录;提供方执行语义的限制见 [LLM 包](../../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。
+
### 开发备注
diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json
index c0f49319e88d..1a75684de146 100644
--- a/packages/core/tools/package.json
+++ b/packages/core/tools/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-tools",
"description": "Tool registry and execution pipeline for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts
index b1effd721e57..5b8b263fa84e 100644
--- a/packages/core/tools/src/index.ts
+++ b/packages/core/tools/src/index.ts
@@ -28,6 +28,13 @@ import { renderToolsSdk } from './ts-types.ts'
import type { ToolSdkSchema } from './ts-types.ts'
import { renderToolsSdkPy } from './py-types.ts'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ /** Tool availability changes supplied by the tool registry. */
+ 'tool-registry': { kind: 'tool-registry' }
+ }
+}
+
/**
* Language → SDK-section renderer. The registry looks up the loaded
* `ctx.ptcRuntime.language` in this table when assembling the `tools:sdk`
@@ -458,9 +465,14 @@ export interface ToolRuntimeScheduler {
/**
* Scheduler entry point omitted from the generated named service API.
+ *
+ * Registered globally: a run can hold this package twice (the Loader instantiates
+ * the built entry while callers resolve the source entry), so a per-copy
+ * `Symbol()` would leave each side with its own key and reads would yield
+ * `undefined` off a working `ToolRuntime`.
* @internal
*/
-export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol('@mutantcat/dsh-tools.scheduler')
+export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol.for('@mutantcat/dsh-tools.scheduler')
/** Canonical error code for cancellation after a tool body was invoked. */
export const TOOL_ABORTED = 'ABORTED'
@@ -1260,7 +1272,7 @@ export class ToolRuntime extends Service {
/** Project one definition onto the model-facing schema fields. */
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
- const { name, description, parameters } = definition
+ const { name, description, parameters, deferLoading } = definition
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
if (detached === undefined) {
throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
@@ -1269,6 +1281,7 @@ export class ToolRuntime extends Service {
name,
description,
parameters: detached,
+ ...deferLoading === true ? { deferLoading } : {},
}
}
diff --git a/packages/core/tools/src/ptc.ts b/packages/core/tools/src/ptc.ts
index 40b95319d5eb..33a4b78c3b08 100644
--- a/packages/core/tools/src/ptc.ts
+++ b/packages/core/tools/src/ptc.ts
@@ -8,6 +8,13 @@
import { brandString } from '@mutantcat/dsh-brand'
import { createUserMessage, HarnessError } from '@mutantcat/dsh-llm'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ /** Images deferred from a successful PTC subcall's final result. */
+ 'ptc-mode': { kind: 'ptc-mode' }
+ }
+}
+
import type { ContentBlock, ToolCallId, ToolSchema } from '@mutantcat/dsh-llm'
import type { PtcBindingFunction, PtcRunResult, PtcRunSandbox, PtcRuntime } from '@mutantcat/dsh-ptc-runtime'
import { approveEscalation, ESCALATION_TARGETS, validateEscalationArgs } from '@mutantcat/dsh-sandbox'
@@ -632,7 +639,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
if (!result.isError && result.content.some(block => block.type === 'image')) {
exec.deferContext(createUserMessage({
content: result.content,
- source: { kind: 'plugin', plugin: 'tools-ptc' },
+ source: { kind: 'ptc-mode' },
}))
}
for (const context of result.additionalContexts ?? []) {
diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts
index 978c5a66f1bd..77bd94385aae 100644
--- a/packages/core/tools/src/schema.ts
+++ b/packages/core/tools/src/schema.ts
@@ -496,6 +496,8 @@ export interface DefineToolOptions, value: InferValue>): JsonValue
}
+ /** Requests deferred loading of the tool definition; see {@link @mutantcat/dsh-llm#ToolSchema.deferLoading}. */
+ readonly deferLoading?: true
/** Optional positive cooperative timeout budget in milliseconds. */
readonly timeoutMs?: number
/**
@@ -581,6 +583,7 @@ export function defineTool {
const violations = validate(args)
diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts
index 9efa9e419a9b..a0e8fb5aac42 100644
--- a/packages/core/tools/tests/ptc.spec.ts
+++ b/packages/core/tools/tests/ptc.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@mutantcat/cordis'
import { createUserMessage, ToolCallId } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import type { ToolSchema } from '@mutantcat/dsh-llm'
import { createScope } from '@mutantcat/dsh-scope'
import type { Scope } from '@mutantcat/dsh-scope'
@@ -17,6 +18,13 @@ import type { JsonValue } from '@mutantcat/dsh-util-values'
import SandboxPolicy from '@mutantcat/dsh-sandbox-policy'
import SessionProjections from '@mutantcat/dsh-session-projection'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'order-probe': { kind: 'order-probe' } & ContextFormed
+ 'test': { kind: 'test' } & ContextFormed
+ }
+}
+
const testToolSignal = new AbortController().signal
/**
@@ -818,7 +826,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => {
kind: 'accept' as const,
additionalContexts: [createUserMessage({
content: [{ type: 'text' as const, text: `ctx:${String(postExec.callId)}` }],
- source: { kind: 'plugin' as const, plugin: 'order-probe' },
+ source: { kind: 'order-probe' as const },
})],
}
}
@@ -1322,7 +1330,7 @@ describe('the run_code dispatch bridge', () => {
kind: 'accept' as const,
additionalContexts: [createUserMessage({
content: [{ type: 'text' as const, text: `context for ${exec.callId}` }],
- source: { kind: 'plugin' as const, plugin: 'test' },
+ source: { kind: 'test' as const },
})],
})
}
@@ -1339,12 +1347,12 @@ describe('the run_code dispatch bridge', () => {
{
role: 'user',
content: [{ type: 'text', text: 'context for call-1:ptc:1' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
},
{
role: 'user',
content: [{ type: 'text', text: 'context for call-1:ptc:2' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
},
])
})
@@ -1375,7 +1383,7 @@ describe('the run_code dispatch bridge', () => {
expect(result.additionalContexts).toMatchObject([{
role: 'user',
- source: { kind: 'plugin', plugin: 'tools-ptc' },
+ source: { kind: 'ptc-mode' },
content: [
{ type: 'text', text: 'image result' },
{ type: 'image', attachment: { mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
@@ -1425,7 +1433,7 @@ describe('the run_code dispatch bridge', () => {
kind: 'accept',
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'nested context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})],
})
})
@@ -1441,7 +1449,7 @@ describe('the run_code dispatch bridge', () => {
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'nested context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}])
})
diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts
index 5a72ab83fbb6..59cb706ff90c 100644
--- a/packages/core/tools/tests/tools.spec.ts
+++ b/packages/core/tools/tests/tools.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from '@mutantcat/cordis'
import LlmRuntime, { createUserMessage, ToolCallId, HarnessError, type ContentBlock } from '@mutantcat/dsh-llm'
+import type { ContextFormed } from '@mutantcat/dsh-llm'
import SessionStore, { Session, SessionId } from '@mutantcat/dsh-session'
import SessionProjectionRegistry from '@mutantcat/dsh-session-projection'
import SystemPrompt from '@mutantcat/dsh-system-prompt'
@@ -15,6 +16,19 @@ import ToolRuntime, {
} from '@mutantcat/dsh-tools'
import type { JsonValue } from '@mutantcat/dsh-util-values'
+declare module '@mutantcat/dsh-llm' {
+ interface MessageSourceMap {
+ 'blocker': { kind: 'blocker' } & ContextFormed
+ 'child': { kind: 'child' } & ContextFormed
+ 'nested': { kind: 'nested' } & ContextFormed
+ 'nested-1': { kind: 'nested-1' } & ContextFormed
+ 'nested-2': { kind: 'nested-2' } & ContextFormed
+ 'post': { kind: 'post' } & ContextFormed
+ 'test': { kind: 'test' } & ContextFormed
+ 'wrapper': { kind: 'wrapper' } & ContextFormed
+ }
+}
+
const testToolSignal = new AbortController().signal
async function setup() {
@@ -38,6 +52,17 @@ const echoTool = defineTool({
})
describe('ToolRuntime', () => {
+ it('preserves deferred loading through registry and prompt schema projection', async () => {
+ const ctx = await setup()
+ try {
+ ctx.tools.register(defineContentToolFixture({ name: 'deferred', description: '', parameters: {}, deferLoading: true, async execute() { return [] } }))
+ expect(ctx.tools.schemas()[0]?.deferLoading).toBe(true)
+ expect((await ctx.systemPrompt.assemble()).tools[0]?.deferLoading).toBe(true)
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
it('registers tools, exposes schemas, and feeds the system-prompt assembly', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -369,7 +394,7 @@ describe('ToolRuntime', () => {
kind: 'accept',
value: { text: 'policy value' },
additionalContexts: [createUserMessage({
- content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' },
+ content: [{ type: 'text', text: 'value context' }], source: { kind: 'test' },
})],
}
})
@@ -393,7 +418,7 @@ describe('ToolRuntime', () => {
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'value context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}],
})
})
@@ -517,7 +542,7 @@ describe('ToolRuntime', () => {
content: [{ type: 'text', text: 'wrapper content' }],
meta: { wrapped: true },
additionalContexts: [createUserMessage({
- content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' },
+ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'test' },
})],
}))
@@ -531,7 +556,7 @@ describe('ToolRuntime', () => {
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'wrapper context' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}],
})
})
@@ -951,14 +976,14 @@ describe('ToolRuntime', () => {
kind: 'block',
feedback: [{ type: 'text', text: 'rejected' }],
additionalContexts: [createUserMessage({
- content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' },
+ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'test' },
})],
}))
const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'rejected' })
- expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }])
+ expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'test' } }])
})
it('post-execute additionalContexts ride on the result for the loop to buffer', async () => {
@@ -967,11 +992,11 @@ describe('ToolRuntime', () => {
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise =>
({ kind: 'accept', additionalContexts: [createUserMessage({
- content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' },
+ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'test' },
})] }))
const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } })
- expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }])
+ expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'test' } }])
})
it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => {
@@ -982,10 +1007,10 @@ describe('ToolRuntime', () => {
parameters: {},
async execute(_args, exec) {
exec.deferContext(createUserMessage({
- content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' },
+ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'nested-1' },
}))
exec.deferContext(createUserMessage({
- content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' },
+ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'nested-2' },
}))
return [{ type: 'text', text: 'done' }]
},
@@ -997,7 +1022,7 @@ describe('ToolRuntime', () => {
additionalContexts: [
...result.additionalContexts ?? [],
createUserMessage({
- content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'plugin', plugin: 'wrapper' },
+ content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'wrapper' },
}),
],
}
@@ -1008,7 +1033,7 @@ describe('ToolRuntime', () => {
...downstream,
additionalContexts: [
createUserMessage({
- content: [{ type: 'text', text: 'post' }], source: { kind: 'plugin', plugin: 'post' },
+ content: [{ type: 'text', text: 'post' }], source: { kind: 'post' },
}),
...downstream.additionalContexts ?? [],
],
@@ -1018,10 +1043,10 @@ describe('ToolRuntime', () => {
const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('composite'), name: 'composite', arguments: {} })
expect(result.additionalContexts?.map(context => context.source)).toEqual([
- { kind: 'plugin', plugin: 'nested-1' },
- { kind: 'plugin', plugin: 'nested-2' },
- { kind: 'plugin', plugin: 'wrapper' },
- { kind: 'plugin', plugin: 'post' },
+ { kind: 'nested-1' },
+ { kind: 'nested-2' },
+ { kind: 'wrapper' },
+ { kind: 'post' },
])
})
@@ -1033,7 +1058,7 @@ describe('ToolRuntime', () => {
parameters: {},
async execute(_args, exec) {
exec.deferContext(createUserMessage({
- content: [{ type: 'text', text: 'nested' }], source: { kind: 'plugin', plugin: 'nested' },
+ content: [{ type: 'text', text: 'nested' }], source: { kind: 'nested' },
}))
throw new Error('outer failure')
},
@@ -1041,18 +1066,18 @@ describe('ToolRuntime', () => {
const failed = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('failed'), name: 'failing-composite', arguments: {} })
expect(failed.isError).toBe(true)
- expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }])
+ expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'nested' }])
ctx.on('tools/post-execute', async (): Promise => ({
kind: 'block',
feedback: [{ type: 'text', text: 'blocked' }],
additionalContexts: [createUserMessage({
- content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' },
+ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'blocker' },
})],
}))
const blocked = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('blocked'), name: 'failing-composite', arguments: {} })
expect(blocked.isError).toBe(true)
- expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }])
+ expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'blocker' }])
})
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
@@ -1317,7 +1342,7 @@ describe('ToolRuntime', () => {
isError: false,
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'wrapper context' }],
- source: { kind: 'plugin', plugin: 'wrapper' },
+ source: { kind: 'wrapper' },
})],
}
})
@@ -1337,7 +1362,7 @@ describe('ToolRuntime', () => {
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
- additionalContexts: [{ source: { kind: 'plugin', plugin: 'wrapper' } }],
+ additionalContexts: [{ source: { kind: 'wrapper' } }],
})
expect(dispatched).toBe(0)
})
@@ -1350,7 +1375,7 @@ describe('ToolRuntime', () => {
async execute(_args, exec) {
exec.deferContext(createUserMessage({
content: [{ type: 'text', text: 'completed child work' }],
- source: { kind: 'plugin', plugin: 'child' },
+ source: { kind: 'child' },
}))
return 'body complete'
},
@@ -1375,7 +1400,7 @@ describe('ToolRuntime', () => {
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { info: { name: 'AbortError', code: TOOL_ABORTED } },
- additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }],
+ additionalContexts: [{ source: { kind: 'child' } }],
})
})
@@ -1387,7 +1412,7 @@ describe('ToolRuntime', () => {
async execute(_args, exec) {
exec.deferContext(createUserMessage({
content: [{ type: 'text', text: 'completed child work' }],
- source: { kind: 'plugin', plugin: 'child' },
+ source: { kind: 'child' },
}))
return 'body complete'
},
@@ -1402,7 +1427,7 @@ describe('ToolRuntime', () => {
...decision,
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'post context' }],
- source: { kind: 'plugin', plugin: 'post' },
+ source: { kind: 'post' },
})],
}
})
@@ -1419,8 +1444,8 @@ describe('ToolRuntime', () => {
isError: true,
error: { info: { name: 'AbortError', code: TOOL_ABORTED } },
additionalContexts: [
- { source: { kind: 'plugin', plugin: 'child' } },
- { source: { kind: 'plugin', plugin: 'post' } },
+ { source: { kind: 'child' } },
+ { source: { kind: 'post' } },
],
})
})
@@ -1592,7 +1617,7 @@ describe('ToolRuntime', () => {
execute(_args, exec) {
exec.deferContext(createUserMessage({
content: [{ type: 'text', text: 'nested outcome' }],
- source: { kind: 'plugin', plugin: 'nested' },
+ source: { kind: 'nested' },
}))
entered.resolve(undefined)
return release.promise
@@ -1614,7 +1639,7 @@ describe('ToolRuntime', () => {
await expect(pending).resolves.toMatchObject({
isError: true,
error: { info: { name: 'AbortError', code: TOOL_ABORTED } },
- additionalContexts: [{ source: { kind: 'plugin', plugin: 'nested' } }],
+ additionalContexts: [{ source: { kind: 'nested' } }],
})
})
@@ -1882,7 +1907,7 @@ describe('ToolRuntime', () => {
value: 'short-circuited with context',
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'from around dispatch' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
})],
}))
@@ -1894,7 +1919,7 @@ describe('ToolRuntime', () => {
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'from around dispatch' }],
- source: { kind: 'plugin', plugin: 'test' },
+ source: { kind: 'test' },
}])
})
diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json
index 004018f6bbcf..5df59da53e5b 100644
--- a/packages/credentials/authorization/package.json
+++ b/packages/credentials/authorization/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-authorization",
"description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json
index 2641d6e5849e..b36cb96ca7ba 100644
--- a/packages/credentials/credentials-local/package.json
+++ b/packages/credentials/credentials-local/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-credentials-local",
"description": "File-backed credentials provider ($ALPHA_HOME/.env under the live process environment) for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json
index 4558331a16ff..23630ed78f77 100644
--- a/packages/credentials/credentials/package.json
+++ b/packages/credentials/credentials/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-credentials",
"description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/deliverables/tool-present/package.json b/packages/deliverables/tool-present/package.json
index b697cd24f704..7fcfeb62d61c 100644
--- a/packages/deliverables/tool-present/package.json
+++ b/packages/deliverables/tool-present/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-tool-present",
"description": "Explicit workspace file delivery declarations for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts
index c897dd5b2472..7b074d1c83d0 100644
--- a/packages/deliverables/tool-present/tests/present.spec.ts
+++ b/packages/deliverables/tool-present/tests/present.spec.ts
@@ -42,6 +42,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
+ interruptModelStep: () => false,
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
diff --git a/packages/deliverables/workspace-changes/package.json b/packages/deliverables/workspace-changes/package.json
index addce5d2af12..765949d9e3b1 100644
--- a/packages/deliverables/workspace-changes/package.json
+++ b/packages/deliverables/workspace-changes/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-workspace-changes",
"description": "Per-turn workspace file changes recorded from git working-tree snapshots and whole-file captures, with per-file comparisons, for the Alpha",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/document/office-to-pdf/package.json b/packages/document/office-to-pdf/package.json
index 92711e39622a..41b505925629 100644
--- a/packages/document/office-to-pdf/package.json
+++ b/packages/document/office-to-pdf/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-office-to-pdf",
"description": "Shared Office-to-PDF conversion with bounded queues and caching",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json
index 89a37f9586de..5107bfff2b26 100644
--- a/packages/experimental/agent-team-profile/package.json
+++ b/packages/experimental/agent-team-profile/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-experimental-agent-team-profile",
"description": "Experimental profile bundle enabling Agent Teams over dsh-base",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json
index 4a181d04504c..850dd698b9a3 100644
--- a/packages/experimental/agent-team-web-profile/package.json
+++ b/packages/experimental/agent-team-web-profile/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-experimental-agent-team-web-profile",
"description": "Experimental Web profile layer for Agent Teams Remote and UI plugins",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml
index af79edee1d9d..2d66e690ce8c 100644
--- a/packages/experimental/agent-team/README.i18n.yaml
+++ b/packages/experimental/agent-team/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md
-README.md: 029683fc2ad7ae1850d3e49d268adf28e697cb96
-README.zh.md: 9515c29a88674d4b931dde528fb35c61900590f1
+README.md: 1fd7aa10c2bb85766d639b5a3b7cf3b6d26189c7
+README.zh.md: 128df270b5d2483336601bc561fa93a7b4fc3d1c
diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md
index 029683fc2ad7..1fd7aa10c2bb 100644
--- a/packages/experimental/agent-team/README.md
+++ b/packages/experimental/agent-team/README.md
@@ -145,6 +145,8 @@ Tasks are complete versioned snapshots; every mutation carries `expectedRevision
Team events are appended to the exact live Lead Session and flushed before the operation reports success or wakes waiters. `team/member`, `team/task`, `team/message/queued`, and `team/message/delivered` are log-only: they never enter the conversation surface, so derived model history is untouched by coordination records. Session event `seq` and `time` own ordering and timing; snapshots do not duplicate them. The `./invariant` companion replays each candidate Team event against its committed prefix and rejects invalid transitions before append.
+Mailbox projection and checkpoint admission validate known content fields and preserve unknown plugin content as decoded JSON, including own `__proto__` keys. Unknown block fields remain opaque; retired tool-result content is rejected.
+
### Disposal
Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then asks the continuation owner to release the roster's exact live direct children and their descendants; non-Team continuable children of the Lead remain untouched. Cleanup failures make disposal fail visibly, bounded by `disposalTimeoutMs`.
diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md
index 9515c29a8867..128df270b5d2 100644
--- a/packages/experimental/agent-team/README.zh.md
+++ b/packages/experimental/agent-team/README.zh.md
@@ -145,6 +145,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息
Team 事件追加到精确的 live Lead 会话,并在操作报告成功或唤醒等待者之前 flush。`team/member`、`team/task`、`team/message/queued` 与 `team/message/delivered` 仅存在于日志:它们从不进入会话表面,因此派生模型历史不受协作记录影响。顺序与时间由会话事件的 `seq` 与 `time` 负责,快照不重复保存。`./invariant` 伴生插件把每条候选 Team 事件对照已提交前缀回放,并在 append 前拒绝非法转换。
+邮箱投影和检查点准入校验已知内容字段,并按解码后的 JSON 保留未知插件内容,包括自有 `__proto__` 键。未知块字段保持不透明,退役的 tool-result 内容被拒绝。
+
### Dispose
dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch 事务,再让 continuation owner 释放 roster 中确切的 live direct child 及其后代;Lead 的非 Team continuable child 不受影响。cleanup 失败会让 dispose 明确失败,并以 `disposalTimeoutMs` 为上限。
diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json
index 3b7027e82714..4b5f6481ec5e 100644
--- a/packages/experimental/agent-team/package.json
+++ b/packages/experimental/agent-team/package.json
@@ -1,7 +1,7 @@
{
"name": "@mutantcat/dsh-experimental-agent-team",
"description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG",
- "version": "1.0.20261020",
+ "version": "1.0.20261021",
"publishConfig": {
"access": "public"
},
diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts
index e28546a2400c..aa90469ec0d1 100644
--- a/packages/experimental/agent-team/src/projection.ts
+++ b/packages/experimental/agent-team/src/projection.ts
@@ -42,7 +42,7 @@ const imageAttachmentSchema = z.object({
// ContentBlockMap is merge-extensible. Validate every core variant exactly,
// while retaining JSON-decoded plugin variants under an unknown type tag.
-const contentBlockSchema: z.ZodType = z.lazy(() => z.union([
+const contentBlockSchema: z.ZodType = z.union([
z.object({ type: z.literal('text'), text: z.string() }).strict(),
z.object({ type: z.literal('reasoning'), text: z.string() }).strict(),
z.object({ type: z.literal('image'), attachment: imageAttachmentSchema }).strict(),
@@ -52,17 +52,13 @@ const contentBlockSchema: z.ZodType = z.lazy(() => z.union([
name: z.string(),
arguments: z.string(),
}).strict(),
- z.object({
- type: z.literal('tool-result'),
- toolCallId: z.string().min(1),
- content: z.array(contentBlockSchema),
- isError: z.boolean().optional(),
- }).strict(),
- z.object({ type: z.string().min(1) }).loose().refine(
- block => !coreContentBlockTypes.has(block.type),
- { message: 'known content block types must match their declared fields' },
- ),
-])) as z.ZodType
+ // Unknown content is decoded JSON; validation must preserve its own extension keys.
+ z.custom((value) => {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+ const type = (value as { type?: unknown }).type
+ return typeof type === 'string' && type.length > 0 && !coreContentBlockTypes.has(type)
+ }),
+]) as z.ZodType