From fc9255f78c220f649005eb67928d41045577f54d Mon Sep 17 00:00:00 2001 From: chimney Date: Wed, 21 Oct 2026 02:01:34 +0800 Subject: [PATCH 1/3] wip --- src/file/contract.ts | 3 ++- src/file/result.ts | 3 ++- src/kernel/presentation-outcome.ts | 34 ++++++++++++++++++++++++++++++ src/tui/result.ts | 13 +++++++++--- test/tui-native.test.mjs | 7 ++++-- 5 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 src/kernel/presentation-outcome.ts diff --git a/src/file/contract.ts b/src/file/contract.ts index 0fd526b8..937d3cbf 100644 --- a/src/file/contract.ts +++ b/src/file/contract.ts @@ -6,6 +6,7 @@ import { PARAMETERS, DESCRIPTIONS, OUTPUT, fileOutput } from '../capabilities/fi export { FILE_TOOLS } from '../capabilities/registry.js' export { PARAMETERS, DESCRIPTIONS, OUTPUT } import { renderNative } from '../modes/native/render.js' +import { presentationMeta } from '../kernel/presentation-outcome.js' /** Drop unused empty placeholders strict adapters send for optional fields; never mutate the caller's object. */ export function normalizeArguments(rawName: string, args: unknown): unknown { @@ -33,7 +34,7 @@ export function definition(rawName: FileToolName, execute: ToolDefinition['execu description: DESCRIPTIONS[rawName], parameters: PARAMETERS[rawName], ...(capability(rawName)?.concurrencySafe ? { isConcurrencySafe: () => true } : {}), - output: { schema: fileOutput(rawName), render: (args, value) => [{ type: 'text', text: renderNative(rawName, args, value) }] }, + output: { schema: fileOutput(rawName), render: (args, value) => [{ type: 'text', text: renderNative(rawName, args, value) }], presentationMeta }, execute, } } diff --git a/src/file/result.ts b/src/file/result.ts index eace4e91..ef468b94 100644 --- a/src/file/result.ts +++ b/src/file/result.ts @@ -6,12 +6,13 @@ import { publicToolName } from '../policy.js' import { OUTPUT } from '../capabilities/ptc.js' import { invoke, failure } from '../ptc/worker.js' import { renderNative } from '../modes/native/render.js' +import { presentationMeta } from '../kernel/presentation-outcome.js' export function resultDefinition(execute: ToolDefinition['execute']): ToolDefinition { const entry = capability('result') if (!entry || !('nativeProjection' in entry) || !entry.nativeProjection) throw new Error('Native result projection is missing') return { name: publicToolName(entry.rawName), description: entry.nativeProjection.description, parameters: entry.nativeProjection.parameters, execute, - output: { schema: OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative('result', args, value) }] } } + output: { schema: OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative('result', args, value) }], presentationMeta } } } type Execution = Parameters[1] export async function invokeResult(ctx: Parameters[0], file: string, args: unknown, diff --git a/src/kernel/presentation-outcome.ts b/src/kernel/presentation-outcome.ts new file mode 100644 index 00000000..b9c0c556 --- /dev/null +++ b/src/kernel/presentation-outcome.ts @@ -0,0 +1,34 @@ +/** Bounded outcome facts persisted as host presentation metadata: replayable for UIs, never model-facing. */ +export const OUTCOME_META = 'opsOutcome' +type Json = string | number | boolean | null | Json[] | { [key: string]: Json } +const COUNTERS = ['done', 'total', 'failed', 'unknown', 'omitted', 'nextOffset'] as const +const FLAGS = ['complete', 'dryRun'] as const +const EFFECTS = new Set(['none', 'applied', 'partial', 'unknown']) +const record = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value) + +/** Copy only status, the error code/effect and safe counters; paths, messages and payloads never leave the value. */ +export function outcomeFacts(value: unknown): { [key: string]: Json } { + const facts: { [key: string]: Json } = {} + if (!record(value)) return facts + if (typeof value.status === 'string' && /^[a-z][a-z-]{0,31}$/.test(value.status)) facts.status = value.status + for (const key of COUNTERS) { + const count = value[key] + if (typeof count === 'number' && Number.isSafeInteger(count) && count >= 0) facts[key] = count + } + for (const key of FLAGS) { + const flag = value[key] + if (typeof flag === 'boolean') facts[key] = flag + } + if (record(value.error)) { + const error: { [key: string]: Json } = {} + if (typeof value.error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(value.error.code)) error.code = value.error.code + if (typeof value.error.effectState === 'string' && EFFECTS.has(value.error.effectState)) error.effectState = value.error.effectState + facts.error = error + } + return facts +} + +/** dsh-tools `output.presentationMeta` for Native tools whose model text is not a JSON envelope. */ +export function presentationMeta(_args: unknown, value: unknown): Json { + return { [OUTCOME_META]: outcomeFacts(value) } +} diff --git a/src/tui/result.ts b/src/tui/result.ts index 2beba499..10c2b94e 100644 --- a/src/tui/result.ts +++ b/src/tui/result.ts @@ -1,5 +1,6 @@ -/** Read only the six OPS native JSON envelopes, never shell stdout or arbitrary tool prose. */ +/** Read only OPS outcome facts (presentation metadata or JSON envelopes), never shell stdout or arbitrary tool prose. */ import { FILE_TOOLS, publicToolName } from '../policy.js' +import { OUTCOME_META } from '../kernel/presentation-outcome.js' import { PTC_TOOLS } from '../ptc/contract.js' const JSON_TOOLS = new Set([...FILE_TOOLS, ...PTC_TOOLS].map(publicToolName)) const MAX_JSON_BYTES = 64 * 1024 @@ -20,6 +21,10 @@ export function record(value: unknown): value is Record { export function resultDetails(tool: string, data: unknown, nested: boolean): ResultDetails { if (!JSON_TOOLS.has(tool) || !record(data)) return UNCONFIRMED const message = record(data.message) ? data.message : undefined + const hostFailed = data.error !== undefined || (nested ? data.isError === true : message?.isError === true) + // Native results render model text; their outcome travels in the host's replayable presentation metadata. + const meta = !nested && record(data.meta) ? data.meta[OUTCOME_META] : undefined + if (record(meta)) return summarize(meta, hostFailed) const content = nested ? data.content : message?.content // Multiple blocks can be a spill preview plus locator, not the original JSON envelope. if (!Array.isArray(content) || content.length !== 1 || !record(content[0]) || content[0].type !== 'text') return UNCONFIRMED @@ -27,7 +32,10 @@ export function resultDetails(tool: string, data: unknown, nested: boolean): Res if (typeof text !== 'string' || text.length > MAX_JSON_BYTES || Buffer.byteLength(text, 'utf8') > MAX_JSON_BYTES) return UNCONFIRMED let value: unknown try { value = JSON.parse(text) } catch { return UNCONFIRMED } - if (!record(value)) return UNCONFIRMED + return record(value) ? summarize(value, hostFailed) : UNCONFIRMED +} + +function summarize(value: Record, hostFailed: boolean): ResultDetails { const summary: ResultSummary = {} for (const key of ['done', 'total', 'failed', 'unknown', 'omitted', 'nextOffset'] as const) { if (integer(value[key])) summary[key] = value[key] @@ -42,6 +50,5 @@ export function resultDetails(tool: string, data: unknown, nested: boolean): Res : (summary.failed ?? 0) > 0 || value.status === 'partial' ? 'partial' : value.status === 'planned' || (value.status === 'completed' && value.dryRun === true) ? 'planned' : value.status === 'completed' ? 'completed' : 'unconfirmed' - const hostFailed = data.error !== undefined || (nested ? data.isError === true : message?.isError === true) return Object.freeze({ outcome: hostFailed && (outcome === 'completed' || outcome === 'planned') ? 'unconfirmed' : outcome, ...summary }) } diff --git a/test/tui-native.test.mjs b/test/tui-native.test.mjs index bc317fd7..59783fb4 100644 --- a/test/tui-native.test.mjs +++ b/test/tui-native.test.mjs @@ -52,14 +52,17 @@ const run = async (agent, name, args, mode = 'danger-full-access') => { modes.set(agent.id, mode) scripts.set(agent.id, [[{ id: callId, name, args }], []]) const before = modelCalls - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run the scripted fixture.' }] })) + // DSH 0.2.1 loops classify queued input by source; a user turn must say it is one. + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run the scripted fixture.' }], source: { kind: 'user' } })) await agent.whenIdle() assert.deepEqual(failures, []) assert.equal(modelCalls, before + 2, 'one scripted tool response and one final response') assert.equal(scripts.get(agent.id).length, 0) const result = events.find(event => event.type === 'tool/result' && event.data.message.source.callId === callId) assert.ok(result, 'the real loop must commit a tool result') - return JSON.parse(result.data.message.content[0].text) + // Native text is for the model; the panel reads the replayable presentation facts instead. + assert.equal(result.data.message.content[0].text.startsWith('{'), false) + return result.data.meta.opsOutcome } try { await root.plugin(Loader, { baseUrl: pathToFileURL(join(composition, 'cordis.yml')).href }) From ec1051098f4d02f0dd5e3210306cfdbb22d45aa1 Mon Sep 17 00:00:00 2001 From: chimney Date: Wed, 21 Oct 2026 02:02:26 +0800 Subject: [PATCH 2/3] wip2 --- test/tui-result.test.mjs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/tui-result.test.mjs b/test/tui-result.test.mjs index 5700b954..29ad76bd 100644 --- a/test/tui-result.test.mjs +++ b/test/tui-result.test.mjs @@ -2,6 +2,8 @@ import { createActivityStore } from '../lib/tui/activity.js' import { resultDetails } from '../lib/tui/result.js' import { activityLines } from '../lib/tui/panel.js' import { call, result, dispatch } from './lib/activity-events.mjs' +import { definition } from '../lib/file/contract.js' +import { resultDefinition } from '../lib/file/result.js' import { assert, report, test } from './lib/harness.mjs' const inspect = value => resultDetails('ops_grep', result(2, 'a', value).data, false) @@ -18,6 +20,40 @@ await test('normal transport returns distinguish native success, refusal and ope assert.equal(store.snapshot('one').errors, 0) assert.equal(store.snapshot('one').business.rejected, 1) }) +await test('Native results classify from their presentation metadata, never from model text', () => { + const replace = definition('replace', async () => ({})).output + const native = (seq, id, value, output = replace) => ({ type: 'tool/result', seq, time: seq * 10, + data: { turn: 1, step: 1, message: { role: 'tool', toolCallId: id, source: { kind: 'tool', callId: id }, isError: false, + content: output.render({}, value) }, meta: output.presentationMeta({}, value) } }) + for (const [value, outcome] of [[{ status: 'completed', changed: 1 }, 'completed'], [{ status: 'completed', dryRun: true }, 'planned'], + [{ status: 'rejected', error: { code: 'POLICY_DENIED', effectState: 'none', message: 'read-only' } }, 'rejected'], + [{ status: 'error', error: { code: 'NOT_FOUND', effectState: 'none', message: 'missing' } }, 'error'], + [{ status: 'error', error: { code: 'RESULT_UNKNOWN', effectState: 'unknown', message: 'interrupted' } }, 'unknown']]) { + const { data } = native(2, 'a', value) + assert.equal(data.message.content[0].text.startsWith('{'), false, 'Native model text is not a JSON envelope') + assert.equal(resultDetails('ops_replace', data, false).outcome, outcome, JSON.stringify(value)) + // The same Native text without metadata stays unconfirmed: prose is never parsed into an outcome. + assert.equal(resultDetails('ops_replace', { ...data, meta: undefined }, false).outcome, 'unconfirmed') + } + const page = resultDefinition(async () => ({})).output + assert.equal(resultDetails('ops_result', native(2, 'a', { status: 'completed', complete: false, nextOffset: 4 }, page).data, false).complete, false) + const store = createActivityStore() + store.observe('one', call(1, 'a', 'ops_replace')) + store.observe('one', native(2, 'a', { status: 'rejected', error: { code: 'POLICY_DENIED', effectState: 'none', message: 'read-only' } })) + assert.equal(store.snapshot('one').business.rejected, 1); assert.equal(store.snapshot('one').recent[0].errorCode, 'POLICY_DENIED') + const failed = native(2, 'a', { status: 'completed' }); failed.data.message.isError = true + assert.equal(resultDetails('ops_replace', failed.data, false).outcome, 'unconfirmed') + const nested = { content: [{ type: 'text', text: 'not json' }], meta: { opsOutcome: { status: 'completed' } } } + assert.equal(resultDetails('ops_glob', nested, true).outcome, 'unconfirmed', 'PTC children carry JSON, not metadata') +}) +await test('presentation metadata keeps only bounded status, error code/effect and counters', () => { + const meta = definition('grep', async () => ({})).output.presentationMeta({}, { status: 'partial', done: 2, total: 3, failed: 1, + omitted: -1, complete: false, dryRun: 'yes', path: '/private/x', items: [{ path: '/private/x', text: 'PRIVATE' }], + error: { code: 'NOT_FOUND', effectState: 'none', message: '/private/x', nextAction: 'retry' } }) + assert.deepEqual(meta, { opsOutcome: { status: 'partial', done: 2, total: 3, failed: 1, complete: false, error: { code: 'NOT_FOUND', effectState: 'none' } } }) + assert.deepEqual(definition('grep', async () => ({})).output.presentationMeta({}, { status: 'Bad Status', error: { code: 'lower', effectState: 'maybe' } }), + { opsOutcome: { error: {} } }) +}) await test('unknown effects outrank other outcomes and never imply safe automatic retry', () => { assert.equal(inspect({ status: 'completed', unknown: 1 }).outcome, 'unknown') assert.equal(inspect({ status: 'error', error: { effectState: 'unknown' } }).outcome, 'unknown') From a38739b8a61fc294286f5b81f22e83b1dd74c1e3 Mon Sep 17 00:00:00 2001 From: chimney Date: Wed, 21 Oct 2026 02:47:20 +0800 Subject: [PATCH 3/3] fix(tui): document Native outcome metadata and host validation (#339) --- docs/CHANGELOG.md | 3 +++ docs/tui-activity.md | 2 +- docs/tui-panel.md | 4 ++-- docs/tui-panel.zh.md | 4 ++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ffef6522..d1271785 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Classify Native file and `ops_result` outcomes in the OPS TUI sidebar from bounded, replayable presentation metadata rather than compact model text. Preserve PTC child JSON handling and exclude paths, content and error messages from the metadata; cover the producer and consumer in always-on tests and the real dsh-TUI host loop (#339). +- OPS TUI 侧栏通过有界、可重放的展示元数据识别 Native 文件工具和 `ops_result` 的业务结果,不再尝试解析面向模型的精简文本。保留 PTC 子调用的 JSON 处理,元数据不含路径、正文和错误消息;补充 CI 必跑的生产/消费回归及真实 dsh-TUI 宿主闭环测试(#339)。 + ## [0.4.1] — 2026-10-20 — Update locks stop leaving files behind - Stop the update lock from leaving `.dsh-ops-update-*.lock` sidecars behind (#319). The writer that takes the lock removes it on release, and only after proving the name still leads to the inode it locked, so a waiter that opened the old inode can never publish under it. A published write, a request refused after the lock and a directory-wide `replace` now leave the directory exactly as they found it; a sidecar left by a killed writer is not a stuck lock — the next write of that name binds to it, publishes under it and removes it. diff --git a/docs/tui-activity.md b/docs/tui-activity.md index 6157bdf5..a22a3b1b 100644 --- a/docs/tui-activity.md +++ b/docs/tui-activity.md @@ -20,7 +20,7 @@ Snapshots are immutable and referentially stable until that session changes. Sub ## Native result diagnostics -For the four native file tools and two PTC tools only, `result.js` parses a single JSON text envelope of at most 64 KiB (serialized UTF-8 bytes). It retains only known outcome categories, safe nonnegative counters, boolean flags and a bounded uppercase error code. Raw text, items, paths and error messages are discarded. Host failure cannot be upgraded to confirmed success. Shell prose and spilled/malformed output remain business-unconfirmed. Explicit unknown effects are distinguished from merely unavailable business status. No result reference is followed and no progress percentage or token saving is inferred. +For OPS file and PTC tools only, `result.js` reads outcome facts from one of two sources. A Native top-level result carries them in the host's replayable presentation metadata (`meta.opsOutcome`, produced by `presentationMeta` in `src/kernel/presentation-outcome.ts`), because Native model text is compact prose rather than JSON. A PTC child result is a single JSON text envelope of at most 64 KiB (serialized UTF-8 bytes). It retains only known outcome categories, safe nonnegative counters, boolean flags and a bounded uppercase error code. Raw text, items, paths and error messages are discarded. Host failure cannot be upgraded to confirmed success. Shell prose and spilled/malformed output remain business-unconfirmed. Explicit unknown effects are distinguished from merely unavailable business status. No result reference is followed and no progress percentage or token saving is inferred. ## Verification diff --git a/docs/tui-panel.md b/docs/tui-panel.md index a112060d..9d6213bf 100644 --- a/docs/tui-panel.md +++ b/docs/tui-panel.md @@ -24,13 +24,13 @@ The OPS tab does not open itself or change focus. No TUI/React runtime dependenc - Live native and PTC OPS calls for the selected session, since this component was enabled. - Calls, in-flight count, transport returns/errors and recent observed durations. -- Refusal, operation errors, preview/plan, partial failure and explicit unknown effects from native file/PTC JSON envelopes; unknown effects warn against blind retry. +- Refusal, operation errors, preview/plan, partial failure and explicit unknown effects, read from the Native tools' presentation metadata and from PTC JSON envelopes; unknown effects warn against blind retry. - Settled batch counts, omitted details and paging hints, never invented live progress. - Tracking-limit warnings. A return is neutral, **not proof of business success**; a background launch returning does not mean the job finished. This is not history replay or persistent accounting. Closing/reopening the tab preserves observation; unloading clears it. No command, argument, pattern, replacement, path, result body or error text is retained. Session changes select a different snapshot. No extra model/worker calls, job-output reads, polling, prompt sections or token-savings estimates are added. There are no destructive actions or send-to-chat controls. -Shell prose and spilled/truncated content cannot reliably establish business status and remain business-unconfirmed, never success. Only a single JSON text of at most 64 KiB is parsed; fixed categories, counters, booleans and a bounded error code are retained, not error messages. +Shell prose and spilled/truncated content cannot reliably establish business status and remain business-unconfirmed, never success. Native file tools and Native `ops_result` attach a bounded `opsOutcome` object to the host's replayable presentation metadata (status, error code and effect state, safe counters and booleans; never paths, items or messages), because their model text is not JSON. PTC children are classified from a single JSON text of at most 64 KiB. Model prose is never parsed into an outcome. ## Read-only filters and local pages diff --git a/docs/tui-panel.zh.md b/docs/tui-panel.zh.md index 626a5df3..ea57578e 100644 --- a/docs/tui-panel.zh.md +++ b/docs/tui-panel.zh.md @@ -24,13 +24,13 @@ config: - 当前选中会话自该组件启用后观察到的原生与 PTC OPS 调用。 - 调用数、运行中、传输返回/错误与最近观察耗时。 -- 原生文件/PTC 结构化返回中的拒绝、业务失败、预览/计划、部分失败和明确的未知副作用;未知副作用会提示先检查、不要盲目重试。 +- 拒绝、业务失败、预览/计划、部分失败和明确的未知副作用,来自原生工具的展示元数据与 PTC 的 JSON 返回;未知副作用会提示先检查、不要盲目重试。 - 已结算的批处理完成数量、未展示明细数量及分页提示;不伪造实时进度。 - 达到上限时明确标记统计不完整。「已返回」是中性状态,**不代表业务成功**;后台任务启动调用返回不代表任务已经完成。 这不是历史重放或持久用量统计。关闭再打开面板不重置观测,卸载组件才清空。不保留命令、参数、搜索模式、替换文本、路径、结果正文或错误正文。切换会话只切换快照,不增加模型/worker 调用、job_output 读取、轮询、提示词段落或 token 节省估算。不提供删除、重试、提权或发送给模型按钮。 -Shell 输出或被 spill/截断的内容无法可靠恢复业务状态,会保持「业务状态未确认」,不会假报成功。只解析最多 64 KiB 的单条 JSON 文本,且只保留固定类别、计数、布尔值和有界错误码,不保留错误正文。 +Shell 输出或被 spill/截断的内容无法可靠恢复业务状态,会保持「业务状态未确认」,不会假报成功。原生文件工具与原生 `ops_result` 给模型的是紧凑文本而非 JSON,因此会在宿主可重放的展示元数据里附一个有界的 `opsOutcome` 对象(状态、错误码与副作用状态、安全计数和布尔值;不含路径、条目或错误正文)。PTC 子调用仍只解析最多 64 KiB 的单条 JSON 文本。模型可见的文本从不被解析成业务结论。 ## 只读筛选与本地分页