diff --git a/apps/web/tests/expected/navigation-panes/live-result-evicted.expected.md b/apps/web/tests/expected/navigation-panes/live-result-evicted.expected.md
new file mode 100644
index 0000000000..429e1b5e52
--- /dev/null
+++ b/apps/web/tests/expected/navigation-panes/live-result-evicted.expected.md
@@ -0,0 +1,4 @@
+- tabpanel "Result":
+ - text: Raw result
+ - button "Raw result" [pressed]
+ - paragraph: Raw results are only available for live tool calls
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
index 34a223c41d..d4a26c9b9b 100644
--- a/apps/web/tests/navigation-panes.e2e.ts
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -36,6 +36,7 @@ const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', impor
const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md')
const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md')
const COPY_MARKDOWN_EXPECTED = join(CODE_CARD_DIR, 'copy-markdown.expected.md')
+const EVICTED_RESULT_EXPECTED = join(CODE_CARD_DIR, 'live-result-evicted.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl`
@@ -630,6 +631,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
await assertFixtureInventory(CODE_CARD_DIR, [
'code-card.expected.md', 'command-wrap.expected.md', 'copy-markdown.expected.md',
+ 'live-result-evicted.expected.md',
])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md',
@@ -676,6 +678,22 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await expect.poll(() => details.getByRole('tabpanel').textContent()).toContain('answer:')
expect(await details.getByRole('tabpanel').textContent()).toContain('42')
expect(await details.getByText('Raw results are only available for live tool calls').count()).toBe(0)
+ const newer = await scaffold.ctx.tools.execute({
+ agent, signal: new AbortController().signal,
+ calls: Array.from({ length: 1000 }, (_, index) => ({
+ type: 'tool-call' as const, id: ToolCallId(`newer-result-${index}`), name: 'live_result_probe', arguments: '{}',
+ })),
+ })
+ expect(newer.results).toHaveLength(1000)
+ expect(newer.results.every(entry => !entry.result.isError)).toBe(true)
+ await details.getByText('Raw results are only available for live tool calls', { exact: true }).waitFor()
+ expect(await details.getByRole('button', { name: 'Raw result', exact: true }).getAttribute('aria-pressed')).toBe('true')
+ // Keep hover and focus tooltips out of the recorded result panel.
+ await details.getByText('Raw results are only available for live tool calls', { exact: true }).click()
+ await details.getByRole('tooltip').waitFor({ state: 'hidden' })
+ await compareOrRefreshGolden(
+ EVICTED_RESULT_EXPECTED, await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd), MODE,
+ )
await details.getByRole('button', { name: 'Raw result', exact: true }).click()
await details.getByText('LIVE_RESULT_RENDERED', { exact: true }).waitFor()
})
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 73bc63d945..9d1af4b73e 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -706,6 +706,23 @@ export type ThemePreference = typeof THEME_PREFERENCES[number]
```
+
+
+
+## `@deepseek-ai/dsh-client-ui-trajectory`
+
+- `inject`: `sessions` · `typert`
+- `source`: [`packages/client/ui-trajectory/src/index.ts:32`](../packages/client/ui-trajectory/src/index.ts)
+
+```ts config-catalog
+/** Count limit for each live Session's transient tool results. */
+export interface Config {
+ /** Maximum retained results per Session, including values awaiting a result event. */
+ maxResultsPerSession: number
+}
+```
+
+
@@ -4754,7 +4771,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-client-ui-skill` | — | [`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-subagent` | — | [`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-tool` | — | [`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts) |
-| `@deepseek-ai/dsh-client-ui-trajectory` | `sessions` · `typert` | [`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-user-questions` | — | [`packages/client/ui-user-questions/src/index.ts`](../packages/client/ui-user-questions/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-workflow-run` | — | [`packages/client/ui-workflow-run/src/index.ts`](../packages/client/ui-workflow-run/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-workspace` | — | [`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts) |
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index f9bfc6f129..ae4c285629 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -708,6 +708,23 @@ export type ThemePreference = typeof THEME_PREFERENCES[number]
```
+
+
+
+## `@deepseek-ai/dsh-client-ui-trajectory`
+
+- `inject`: `sessions` · `typert`
+- `source`: [`packages/client/ui-trajectory/src/index.ts:32`](../packages/client/ui-trajectory/src/index.ts)
+
+```ts config-catalog
+/** Count limit for each live Session's transient tool results. */
+export interface Config {
+ /** Maximum retained results per Session, including values awaiting a result event. */
+ maxResultsPerSession: number
+}
+```
+
+
@@ -4756,7 +4773,6 @@ export interface Config {
| `@deepseek-ai/dsh-client-ui-skill` | — | [`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-subagent` | — | [`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-tool` | — | [`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts) |
-| `@deepseek-ai/dsh-client-ui-trajectory` | `sessions` · `typert` | [`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-user-questions` | — | [`packages/client/ui-user-questions/src/index.ts`](../packages/client/ui-user-questions/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-workflow-run` | — | [`packages/client/ui-workflow-run/src/index.ts`](../packages/client/ui-workflow-run/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-workspace` | — | [`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts) |
diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md
index 929dd30365..c78e091049 100644
--- a/docs/subsystems/web-client.md
+++ b/docs/subsystems/web-client.md
@@ -116,8 +116,9 @@ Read-only Remote inspection of tool values produced in this Session lifetime.
```ts cordis-catalog
/**
* Observe one committed result without loading a Session or reading its history.
- * Values disappear when the exact Session instance leaves the registry. A cold
- * read stays unavailable; opening a new stream observes the current instance.
+ * Values disappear on count eviction or when the exact Session instance leaves
+ * the registry. A cold read stays unavailable; opening a new stream observes
+ * the current instance. Inspection does not extend a result's retention.
* @param request - Authorized Session identity and durable result event sequence.
* @param signal - Remote consumer lifetime.
* @returns Current availability and changes until cancellation or service disposal.
diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md
index 130146441e..91b1a891b0 100644
--- a/docs/subsystems/web-client.zh.md
+++ b/docs/subsystems/web-client.zh.md
@@ -116,8 +116,9 @@ Read-only Remote inspection of tool values produced in this Session lifetime.
```ts cordis-catalog
/**
* Observe one committed result without loading a Session or reading its history.
- * Values disappear when the exact Session instance leaves the registry. A cold
- * read stays unavailable; opening a new stream observes the current instance.
+ * Values disappear on count eviction or when the exact Session instance leaves
+ * the registry. A cold read stays unavailable; opening a new stream observes
+ * the current instance. Inspection does not extend a result's retention.
* @param request - Authorized Session identity and durable result event sequence.
* @param signal - Remote consumer lifetime.
* @returns Current availability and changes until cancellation or service disposal.
diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml
index 9beaaec8c9..dddabcaefb 100644
--- a/packages/client/ui-trajectory/README.i18n.yaml
+++ b/packages/client/ui-trajectory/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 0f4ee2189324ac06
zh: 12e76fed22c09bc1
/deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation:
- en: 436c9201f508354c
- zh: e332adcf07a6f272
+ en: 7cb2aad8fd238ced
+ zh: 2820d37b20b9cb70
/deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation/virtual-rows:
en: c85e342bc7eee4e6
zh: 9feab66ef1755b94
diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md
index cd8c18b5ce..5a6caa76f4 100644
--- a/packages/client/ui-trajectory/README.md
+++ b/packages/client/ui-trajectory/README.md
@@ -57,13 +57,13 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re
User, steering, and context inputs and request prompt changes share their request's Step group in log order, before the request separator and assistant output.
-Native and nested PTC Tool results retain their raw structured error details and presentation metadata. The Result tab switches between rendered content and a live canonical value with a compact toggle. Rendered content is the default; the browser-wide preference survives record, tab, and Session changes and reloads. Historical, failed, and unloaded results remain switchable but show “Raw results are only available for live tool calls” without resetting the preference. The inspector subscribes only to the selected raw result and releases it on selection changes or disposal; Session unload and connection loss clear the displayed value. JSON primitives, including `null`, remain inspectable and copyable. Summary previews continue to show rendered content. Failed records show the error code in the ledger and the error name and code in the inspector.
+Native and nested PTC Tool results retain their raw structured error details and presentation metadata. The Result tab switches between rendered content and a live canonical value with a compact toggle. Rendered content is the default; the browser-wide preference survives record, tab, and Session changes and reloads. Historical, failed, evicted, and unloaded results remain switchable but show “Raw results are only available for live tool calls” without resetting the preference. The inspector subscribes only to the selected raw result and releases it on selection changes or disposal; Session unload and connection loss clear the displayed value. JSON primitives, including `null`, remain inspectable and copyable. Summary previews continue to show rendered content. Failed records show the error code in the ledger and the error name and code in the inspector.
Tool records begin at durable tool/call events and retain complete argument text with a lazy `args` view, reused by paired results. Chat's transient preparing stage does not create Trajectory tool rows or alter historical tool timing.
A complete appended prompt without a loaded request header appears as a standalone system row; only its known text is available, with no inferred request options or tool catalog. Prepending its request history replaces that standalone presentation without duplicating the prompt. In-history system prompt changes compare against the most recent request state, including earlier prompt updates without a new request header. Each request retains the prompt and change that applied at its own position. Surface replacements, including compaction, restore the last nonempty surviving system prompt even without a new system event; an unloaded prompt remains unavailable until its page arrives.
-The Trajectory Host plugin owns `ctx.sessionToolResults` and its `toolResults.watch` Remote stream, which inspects a successful tool’s final post-policy value by Session and result-event sequence. Values are held only in memory for the exact live Session instance: unloading the Session clears pending and committed values and invalidates open inspectors. Cold reads, restored sessions, forks, and exports do not recover or inherit values. A new inspector or reconnect can read retained values while that instance survives. Unloading the Trajectory plugin also clears every value; remounting starts empty even when the Session stays live. This capability never writes Session events or projection checkpoints; retained memory grows with successful result sizes during that Session lifetime. The browser subscribes only to the selected result, and ordinary failures leave rendered content usable.
+The Trajectory Host plugin owns `ctx.sessionToolResults` and its `toolResults.watch` Remote stream, which inspects a successful tool’s final post-policy value by Session and result-event sequence. Values are held only in memory for the exact live Session instance: unloading the Session clears pending and committed values and invalidates open inspectors. Cold reads, restored sessions, forks, and exports do not recover or inherit values. A new inspector or reconnect can read retained values while that instance survives. Unloading the Trajectory plugin also clears every value; remounting starts empty even when the Session stays live. The Host config `maxResultsPerSession` defaults to 1000 and must be a positive integer. Pending and committed results share this per-Session count limit; new results evict the oldest entries and notify open inspectors. Committing a retained pending value moves it to the tail; inspection does not extend retention. This limit counts results, not bytes. This capability never writes Session events or projection checkpoints. The browser subscribes only to the selected result, and ordinary failures leave rendered content usable.
### Virtual rows
diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md
index 620052ca7a..2ffb93a69a 100644
--- a/packages/client/ui-trajectory/README.zh.md
+++ b/packages/client/ui-trajectory/README.zh.md
@@ -57,13 +57,13 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间
用户消息、中途引导、上下文输入及请求提示词变更按日志顺序放在所属请求的 Step 分组内,位于请求分隔行和助手输出之前。
-原生与嵌套 PTC 工具结果保留原始结构化错误详情和展示元数据。结果标签页通过紧凑按钮切换渲染内容与实时规范值。默认显示渲染内容;浏览器级偏好在切换记录、标签页、Session 及刷新页面后保留。历史、失败及已卸载的结果仍可切换,但显示“原始结果仅在实时工具调用中可用”,不重置偏好。检查器只订阅当前选中的原始结果,在切换选择或销毁时释放;Session 卸载及连接断开会清除已展示的值。包括 `null` 在内的 JSON 基本值均可查看和复制。概述预览仍显示渲染内容。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。
+原生与嵌套 PTC 工具结果保留原始结构化错误详情和展示元数据。结果标签页通过紧凑按钮切换渲染内容与实时规范值。默认显示渲染内容;浏览器级偏好在切换记录、标签页、Session 及刷新页面后保留。历史、失败、已淘汰及已卸载的结果仍可切换,但显示“原始结果仅在实时工具调用中可用”,不重置偏好。检查器只订阅当前选中的原始结果,在切换选择或销毁时释放;Session 卸载及连接断开会清除已展示的值。包括 `null` 在内的 JSON 基本值均可查看和复制。概述预览仍显示渲染内容。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。
工具记录从持久 tool/call 事件开始,保留完整参数文本和由配对结果复用的懒计算 `args` 视图。Chat 的瞬态 preparing 阶段不创建 Trajectory 工具行,也不改变历史工具计时。
完整的追加提示词在请求头未加载时显示为独立系统行;仅提供已知文本,不推断请求选项或工具目录。补入其请求历史后,该独立展示被替代而不重复提示词。历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。包括压缩在内的 surface 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。
-Trajectory 的 Host 插件拥有 `ctx.sessionToolResults` 服务及其 `toolResults.watch` Remote 流,按 Session 与结果事件序号查看成功调用经过策略处理后的最终值。值仅保存在具体活跃 Session 实例的内存中:卸载 Session 会清除待关联及已提交的值,并使打开的检查器失效。冷读、恢复的会话、fork 及导出不会恢复或继承值;只要原实例仍存活,新检查器或重连就能读取已保留的值。卸载 Trajectory 插件也会清空所有值;即使 Session 仍存活,重新挂载也从空状态开始。此能力不写 Session 事件或投影 checkpoint;保留内存在 Session 存活期间随成功结果大小增长。浏览器只订阅选中的结果,普通读取失败不影响渲染内容的查看。
+Trajectory 的 Host 插件拥有 `ctx.sessionToolResults` 服务及其 `toolResults.watch` Remote 流,按 Session 与结果事件序号查看成功调用经过策略处理后的最终值。值仅保存在具体活跃 Session 实例的内存中:卸载 Session 会清除待关联及已提交的值,并使打开的检查器失效。冷读、恢复的会话、fork 及导出不会恢复或继承值;只要原实例仍存活,新检查器或重连就能读取已保留的值。卸载 Trajectory 插件也会清空所有值;即使 Session 仍存活,重新挂载也从空状态开始。Host 配置 `maxResultsPerSession` 默认为 1000,必须是正整数。待关联及已提交的结果共用每个 Session 的条数上限;新结果会淘汰最老的条目,并通知打开的检查器。待关联值提交后移至末尾;查看结果不会延长其保留时间。此上限按条数计量,不统计字节。此能力不写 Session 事件或投影 checkpoint。浏览器只订阅选中的结果,普通读取失败不影响渲染内容的查看。
### 虚拟行
diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json
index c16307471b..8628230f9a 100644
--- a/packages/client/ui-trajectory/package.json
+++ b/packages/client/ui-trajectory/package.json
@@ -95,6 +95,7 @@
],
"dependencies": {
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
- "zod": "4.4.3"
+ "zod": "4.4.3",
+ "@deepseek-ai/schemastery": "workspace:~"
}
}
diff --git a/packages/client/ui-trajectory/src/index.ts b/packages/client/ui-trajectory/src/index.ts
index 3d668551b4..04cc1f89a3 100644
--- a/packages/client/ui-trajectory/src/index.ts
+++ b/packages/client/ui-trajectory/src/index.ts
@@ -1,6 +1,7 @@
/** Trajectory Host inspector retaining tool values only for live Session instances. */
import { addAbortListener } from 'node:events'
import { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
import type { ToolCallId } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-tools'
@@ -19,37 +20,55 @@ declare module '@deepseek-ai/cordis' {
}
interface LiveResults {
- readonly pending: Map
- readonly values: Map
+ readonly values: Map
readonly listeners: Set<(seq?: number) => void>
}
function emptyResults(): LiveResults {
- return { pending: new Map(), values: new Map(), listeners: new Set() }
+ return { values: new Map(), listeners: new Set() }
+}
+
+/** Count limit for each live Session's transient tool results. */
+export interface Config {
+ /** Maximum retained results per Session, including values awaiting a result event. */
+ maxResultsPerSession: number
}
/** Read-only Remote inspection of tool values produced in this Session lifetime. */
export default class SessionToolResults extends TypertRemoteService {
static inject = ['sessions', 'typert']
+ static Config: z, Config> = z.object({
+ maxResultsPerSession: z.natural().min(1).default(1000),
+ })
private readonly results = new Map()
private readonly lifetime = new AbortController()
- /** @param ctx - Owner of the live Session registry and tool result notifications. */
- constructor(ctx: Context) {
+ /**
+ * @param ctx - Owner of the live Session registry and tool result notifications.
+ * @param config - Validated per-Session retention limit.
+ */
+ constructor(ctx: Context, config: Config) {
super(ctx, 'sessionToolResults', { namespace: 'toolResults' })
ctx.on('tools/result', (exec, result) => {
const session = exec.agent?.session
if (result.isError || session === undefined || ctx.sessions.get(session.id) !== session) return
- this.forSession(session).pending.set(exec.callId, result.value)
+ const state = this.forSession(session)
+ state.values.set(exec.callId, result.value)
+ if (state.values.size <= config.maxResultsPerSession) return
+ for (const oldest of state.values.keys()) {
+ state.values.delete(oldest)
+ if (typeof oldest === 'number') for (const listener of state.listeners) listener(oldest)
+ break
+ }
}, { global: true })
ctx.on('session/event', (session, event) => {
const callId = event.type === 'tool/result' ? event.data.message.toolCallId
: event.type === 'tool/ptc-dispatch' ? event.data.subCallId : undefined
if (callId === undefined) return
const state = this.results.get(session)
- const value = state?.pending.get(callId)
+ const value = state?.values.get(callId)
if (state === undefined || value === undefined) return
- state.pending.delete(callId)
+ state.values.delete(callId)
state.values.set(event.seq, value)
for (const listener of state.listeners) listener(event.seq)
}, { global: true })
@@ -57,13 +76,12 @@ export default class SessionToolResults extends TypertRemoteService {
const state = this.results.get(session)
if (state === undefined) return
this.results.delete(session)
- state.pending.clear()
state.values.clear()
for (const listener of state.listeners) listener()
}, { global: true })
ctx.effect(() => () => {
this.lifetime.abort()
- for (const state of this.results.values()) { state.pending.clear(); state.values.clear() }
+ for (const state of this.results.values()) state.values.clear()
this.results.clear()
}, 'tool-results: live values and followers')
}
@@ -84,8 +102,9 @@ export default class SessionToolResults extends TypertRemoteService {
/**
* Observe one committed result without loading a Session or reading its history.
- * Values disappear when the exact Session instance leaves the registry. A cold
- * read stays unavailable; opening a new stream observes the current instance.
+ * Values disappear on count eviction or when the exact Session instance leaves
+ * the registry. A cold read stays unavailable; opening a new stream observes
+ * the current instance. Inspection does not extend a result's retention.
* @param request - Authorized Session identity and durable result event sequence.
* @param signal - Remote consumer lifetime.
* @returns Current availability and changes until cancellation or service disposal.
diff --git a/packages/client/ui-trajectory/tests/tool-results.host.spec.ts b/packages/client/ui-trajectory/tests/tool-results.host.spec.ts
index ba4e60cd79..4a61ff614d 100644
--- a/packages/client/ui-trajectory/tests/tool-results.host.spec.ts
+++ b/packages/client/ui-trajectory/tests/tool-results.host.spec.ts
@@ -8,14 +8,14 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
-import SessionToolResults from '../src/index.ts'
+import SessionToolResults, { type Config } from '../src/index.ts'
-async function setup(value: JsonValue = { answer: 42 }) {
+async function setup(value: JsonValue = { answer: 42 }, config: Partial = {}) {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(TypertRegistry)
- const feature = ctx.plugin(SessionToolResults)
+ const feature = ctx.plugin(SessionToolResults, config)
await feature
const results = ctx.sessionToolResults
ctx.effect(() => ctx.tools.register(defineTool({
@@ -31,18 +31,19 @@ async function setup(value: JsonValue = { answer: 42 }) {
await owner
const events: SessionEvent[] = []
ctx.on('session/event', (session, event) => { if (session === agent.session) events.push(event) }, { global: true })
+ let call = 0
async function execute() {
await ctx.tools.execute({
agent, signal: new AbortController().signal, log: { turn: 1, step: 1 },
- calls: [{ type: 'tool-call', id: ToolCallId('value-call'), name: 'value', arguments: '{}' }],
+ calls: [{ type: 'tool-call', id: ToolCallId(`value-call-${call++}`), name: 'value', arguments: '{}' }],
})
const event = events.findLast(event => event.type === 'tool/result')
if (event === undefined) throw new Error('tool result missing')
return event
}
- function watch(seq: number) {
+ function watch(seq: number, sessionId = agent.session.id) {
const abort = new AbortController()
- const iterator = results.watch({ sessionId: agent.session.id, seq }, abort.signal)[Symbol.asyncIterator]()
+ const iterator = results.watch({ sessionId, seq }, abort.signal)[Symbol.asyncIterator]()
onTestFinished(async () => { abort.abort(); await iterator.return?.() })
return iterator
}
@@ -56,6 +57,67 @@ it.each([null, false, 0, '', { answer: 42 }])('retains final live value %j witho
expect((await watch(event.seq).next()).value).toEqual({ available: true, value })
})
+it('defaults to 1000 results and rejects invalid count limits', () => {
+ expect(SessionToolResults.Config({}).maxResultsPerSession).toBe(1000)
+ for (const maxResultsPerSession of [0, -1, 1.5, Infinity]) {
+ expect(() => SessionToolResults.Config({ maxResultsPerSession })).toThrow()
+ }
+})
+
+it('retains the latest 1000 results and invalidates an inspector when its result is evicted', async () => {
+ const { execute, watch } = await setup()
+ const first = await execute()
+ const second = await execute()
+ for (let i = 2; i < 1000; i++) await execute()
+ const observer = watch(first.seq)
+ expect((await observer.next()).value).toEqual({ available: true, value: { answer: 42 } })
+ const evicted = observer.next()
+ const latest = await execute()
+ expect((await evicted).value).toEqual({ available: false })
+ expect((await watch(first.seq).next()).value).toEqual({ available: false })
+ expect((await watch(second.seq).next()).value).toEqual({ available: true, value: { answer: 42 } })
+ expect((await watch(latest.seq).next()).value).toEqual({ available: true, value: { answer: 42 } })
+})
+
+it('keeps one result per Session with a limit of one, independently of other Sessions', async () => {
+ const { ctx, execute, watch } = await setup(null, { maxResultsPerSession: 1 })
+ const first = await execute()
+ const other = await ctx.agentLoop.create(SessionId('other-values'))
+ await ctx.tools.execute({
+ agent: other, signal: new AbortController().signal, log: { turn: 1, step: 1 },
+ calls: [{ type: 'tool-call', id: ToolCallId('other-call'), name: 'value', arguments: '{}' }],
+ })
+ expect((await watch(first.seq).next()).value).toEqual({ available: true, value: null })
+ const latest = await execute()
+ expect((await watch(first.seq).next()).value).toEqual({ available: false })
+ expect((await watch(latest.seq).next()).value).toEqual({ available: true, value: null })
+ expect((await watch(other.session.seq - 1, other.id).next()).value).toEqual({ available: true, value: null })
+})
+
+it('counts pending PTC results toward the same limit and cannot restore an evicted value on commit', async () => {
+ const { ctx, agent, execute, watch } = await setup(false, { maxResultsPerSession: 2 })
+ const first = await execute()
+ const firstObserver = watch(first.seq)
+ expect((await firstObserver.next()).value).toEqual({ available: true, value: false })
+ const evicted = firstObserver.next()
+ const callIds = ['pending-1', 'pending-2', 'pending-3'].map(ToolCallId)
+ for (const id of callIds) {
+ await ctx.tools.execute({
+ agent, signal: new AbortController().signal,
+ calls: [{ type: 'tool-call', id, name: 'value', arguments: '{}' }],
+ })
+ }
+ expect((await evicted).value).toEqual({ available: false })
+ for (const [index, subCallId] of callIds.entries()) {
+ const event = agent.session.append('tool/ptc-dispatch', {
+ rootCallId: ToolCallId('pending'), parentCallId: ToolCallId('pending'), subCallId,
+ name: 'value', arguments: {}, content: [{ type: 'text', text: 'rendered' }], isError: false,
+ })
+ expect((await watch(event.seq).next()).value).toEqual(index === 0
+ ? { available: false } : { available: true, value: false })
+ }
+})
+
it('releases values on Session unload and never restores them for a same-id instance', async () => {
const { ctx, agent, owner, execute, watch } = await setup()
const event = await execute()
diff --git a/packages/client/ui-trajectory/tsconfig.host.json b/packages/client/ui-trajectory/tsconfig.host.json
index 4267370371..0543d3a52a 100644
--- a/packages/client/ui-trajectory/tsconfig.host.json
+++ b/packages/client/ui-trajectory/tsconfig.host.json
@@ -30,6 +30,9 @@
},
{
"path": "../../typert/protocol"
+ },
+ {
+ "path": "../../../vendor/schemastery"
}
]
}
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 278f123b20..bbac3cd9cc 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -2840,7 +2840,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: '@Remote({ mode: \'stream\' }) async *watch(request: SessionToolResultRequest, signal: AbortSignal): AsyncIterable',
- description: 'Observe one committed result without loading a Session or reading its history. Values disappear when the exact Session instance leaves the registry. A cold read stays unavailable; opening a new stream observes the current instance.',
+ description: 'Observe one committed result without loading a Session or reading its history. Values disappear on count eviction or when the exact Session instance leaves the registry. A cold read stays unavailable; opening a new stream observes the current instance. Inspection does not extend a result\'s retention.',
parameters: [{ name: 'request', description: 'Authorized Session identity and durable result event sequence.' }, { name: 'signal', description: 'Remote consumer lifetime.' }],
returns: 'Current availability and changes until cancellation or service disposal.',
},
diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md
index 99d96d276d..c5b2cc0b8c 100644
--- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md
+++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md
@@ -118,7 +118,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
| `@deepseek-ai/dsh-client-ui-subagent` | no | Subagent conversation catalog, continuation routing UI, and '@' reference source |
| `@deepseek-ai/dsh-client-ui-theme` | yes | Theme plugin: Host bootstrap for the pre-plugin palette; ThemeRuntime for light/dark/system state and icon replacements; --dsw-* token styles and Appearance settings row |
| `@deepseek-ai/dsh-client-ui-tool` | no | Client Tool call-tree renderer and keyed per-tool presentation slot |
-| `@deepseek-ai/dsh-client-ui-trajectory` | no | Trajectory event ledger and timing overview with live tool-result inspection |
+| `@deepseek-ai/dsh-client-ui-trajectory` | yes | Trajectory event ledger and timing overview with live tool-result inspection |
| `@deepseek-ai/dsh-client-ui-user-questions` | no | Web ask_user_question composer takeover and plan-review presentation UI |
| `@deepseek-ai/dsh-client-ui-workflow-run` | no | Durable workflow-run Conversation Node and nested member disclosure for dsh web |
| `@deepseek-ai/dsh-client-ui-workspace` | no | Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots |
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3772f3cf41..e2e248f857 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6641,6 +6641,9 @@ importers:
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:*
version: link:../../typert/protocol
+ '@deepseek-ai/schemastery':
+ specifier: link:../../../vendor/schemastery
+ version: link:../../../vendor/schemastery
zod:
specifier: 4.4.3
version: 4.4.3