mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into feature/directory-listing-tool
This commit is contained in:
@@ -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: a9b6905dcf2b8ef1f75595e567273f7a3150a412
|
||||
README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989
|
||||
README.md: 9fa6cf5251d480be9d2388bdb32393fa2827168c
|
||||
README.zh.md: 5170d5d4b362a0f19ff6953e1636adef9aa7e8b8
|
||||
|
||||
@@ -60,6 +60,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
|
||||
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
|
||||
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
|
||||
- `isAppendSurfaceEvent(event)` / `isReplacementSurfaceEvent(event)` — split a formed surface event by marker variant. Append-origin events are the durable source for a human transcript, which is not the model-visible surface: a landed replacement shadows the range it summarizes, so projecting a transcript from `session.surface` erases conversation the reader already saw. Consumers that must send exactly what the model sees keep reading `session.surface`.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
- `SessionSurface`:实时只读 `nodes` 和 `replaceGeneration` 投影,由 `session.surface` 暴露;候选校验仍由 `Session` 私有。
|
||||
- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。
|
||||
- `isSurfaceEvent(event)`/`isSurfaceEligibleType(type)`:前者将 `SessionEvent` 收窄为形态完整的 surface 事件;后者在校验种子或已加载日志时,检测缺少标记的可进入 surface 事件。
|
||||
- `isAppendSurfaceEvent(event)`/`isReplacementSurfaceEvent(event)`:按标记变体拆分形态完整的 surface 事件。追加来源的事件是人类可读记录(transcript)的持久来源,而该记录并非模型可见的 surface:已落地的替换会遮蔽它所概括的范围,因此从 `session.surface` 投影记录会抹掉读者已经看到的对话。必须准确发送模型所见内容的消费方仍继续读取 `session.surface`。
|
||||
|
||||
### 请求头重建(`request-header.ts`)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOM
|
||||
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
|
||||
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
/**
|
||||
@@ -480,7 +480,8 @@ export class Session {
|
||||
* the ordered surface; `sourceEventSeqs` records provenance (the seq
|
||||
* numbers of events this one derives from). REQUIRED for
|
||||
* {@link SurfaceEventType} events (every message-producing event must
|
||||
* declare how it joins the surface, the sole source of derived history) and
|
||||
* declare how it joins the surface, the sole source of derived model
|
||||
* history) and
|
||||
* rejected by the compiler for non-surface types like `turn/start` or
|
||||
* `assistant/chunk`.
|
||||
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
||||
|
||||
@@ -37,6 +37,36 @@ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
|
||||
return (event as SessionEvent<SurfaceEventType>).surfaceOp !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an event to an append-origin surface event: one that entered the
|
||||
* surface at its own log position and was never itself a replacement copy.
|
||||
*
|
||||
* The model-visible surface deliberately shadows replaced ranges, so it is the
|
||||
* wrong source for a human transcript — a landed replacement would erase
|
||||
* conversation the user already saw. Append-origin events are that transcript's
|
||||
* durable source material; replacement copies stay model-only.
|
||||
* @param event - event to test.
|
||||
* @returns true when the event appended to the surface tail.
|
||||
*/
|
||||
export function isAppendSurfaceEvent(
|
||||
event: SessionEvent,
|
||||
): event is SurfaceEvent & { surfaceOp: 'append' } {
|
||||
return isSurfaceEvent(event) && event.surfaceOp === 'append'
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an event to a surface replacement: a node that shadowed an existing
|
||||
* surface range instead of appending to the tail. The counterpart of
|
||||
* {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
|
||||
* @param event - event to test.
|
||||
* @returns true when the event replaced a surface range.
|
||||
*/
|
||||
export function isReplacementSurfaceEvent(
|
||||
event: SessionEvent,
|
||||
): event is SurfaceEvent & { surfaceOp: Extract<SurfaceOp, { op: 'replace' }> } {
|
||||
return isSurfaceEvent(event) && event.surfaceOp !== 'append'
|
||||
}
|
||||
|
||||
/** One replacement operation observed while folding a session surface. */
|
||||
export interface SurfaceFoldReplacement {
|
||||
/** Seq of the event that replaced the prior surface range. */
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Session,
|
||||
SessionId,
|
||||
foldSurface,
|
||||
isAppendSurfaceEvent,
|
||||
isReplacementSurfaceEvent,
|
||||
isSurfaceEligibleType,
|
||||
isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
@@ -861,6 +863,40 @@ describe('surface type guards', () => {
|
||||
expect(isSurfaceEligibleType(markerless.type)).toBe(true)
|
||||
expect(isSurfaceEvent(markerless)).toBe(false)
|
||||
})
|
||||
|
||||
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' },
|
||||
}), { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] })
|
||||
const appended = s.events.find(e => e.type === 'user/message')!
|
||||
const replacement = s.events.at(-1)!
|
||||
|
||||
expect(isAppendSurfaceEvent(appended)).toBe(true)
|
||||
expect(isReplacementSurfaceEvent(appended)).toBe(false)
|
||||
expect(isAppendSurfaceEvent(replacement)).toBe(false)
|
||||
expect(isReplacementSurfaceEvent(replacement)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects log-only and markerless events from both marker guards', () => {
|
||||
const s = surfaceSession()
|
||||
const turnStart = s.events.find(e => e.type === 'turn/start')!
|
||||
// A surface-eligible type whose mandatory marker is absent has no origin at
|
||||
// all: it never entered the surface.
|
||||
const markerless: SessionEvent = {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 0,
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}),
|
||||
}
|
||||
|
||||
expect(isAppendSurfaceEvent(turnStart)).toBe(false)
|
||||
expect(isReplacementSurfaceEvent(turnStart)).toBe(false)
|
||||
expect(isAppendSurfaceEvent(markerless)).toBe(false)
|
||||
expect(isReplacementSurfaceEvent(markerless)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SurfaceManager.replaceGeneration', () => {
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 0a3d29e41dd0e203c2f576192ea7e88ecb904fac
|
||||
README.zh.md: b91f4e697ff04eedaaa2fc093229f1c459a1655a
|
||||
README.md: 8f9deb6add7d30bf1609cc7febcb1febafe392c1
|
||||
README.zh.md: 399d45208b6d3f4152c27556523b6944432bec66
|
||||
|
||||
@@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
|
||||
|
||||
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
|
||||
|
||||
`session.history` pages on append-origin message boundaries: `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
|
||||
|
||||
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
@@ -60,14 +60,18 @@ import { openNativePath } from './native-path-opener.ts'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Surface message event types (the pagination counting unit). */
|
||||
/** Conversation message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
|
||||
* naturally includes the in-progress partial.
|
||||
* Message-boundary pagination: count maxMessages append-origin messages
|
||||
* backwards from the window tail. Replacement copies never entered the
|
||||
* conversation a reader sees — they restate a shadowed range for the model
|
||||
* alone — so they consume no quota; the page stays one contiguous raw range,
|
||||
* which keeps a compaction's log-only provenance on the same page as its
|
||||
* replacement. The cut is the starting seq of the oldest message group (chunks
|
||||
* group via sourceEventSeqs — never cut mid-message). The tail page naturally
|
||||
* includes the in-progress partial.
|
||||
*/
|
||||
function paginate(
|
||||
events: readonly SessionEvent[],
|
||||
@@ -79,7 +83,7 @@ function paginate(
|
||||
let cut = 0
|
||||
for (let i = window.length - 1; i >= 0; i--) {
|
||||
const event = window[i] as SessionEvent
|
||||
if (!MESSAGE_TYPES.has(event.type)) continue
|
||||
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
|
||||
count++
|
||||
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
|
||||
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
|
||||
|
||||
@@ -186,9 +186,11 @@ export interface SessionsApi {
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to message boundaries: one page =
|
||||
* all raw events owned by a whole number of messages (including their chunk / tool events),
|
||||
* never cut mid-message. The tail page (beforeSeq absent) additionally carries the in-flight
|
||||
* Reads a window of history events; page boundaries align to append-origin message
|
||||
* boundaries: one page = all raw events owned by a whole number of such messages (including
|
||||
* their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
|
||||
* `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail
|
||||
* page (beforeSeq absent) additionally carries the in-flight
|
||||
* partial — chunk events already emitted for the last unfinalized message.
|
||||
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
|
||||
* presenter produced one, evaluated against the registry at pagination time); the client
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { CallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
@@ -35,6 +35,35 @@ function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'pr
|
||||
})
|
||||
}
|
||||
|
||||
/** Append a production-shaped human prompt to the session surface. */
|
||||
function appendUserText(session: Session, text: string): SessionEvent {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Append a production-shaped assistant message to the session surface. */
|
||||
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
|
||||
return session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a plugin-owned log-only event. The host proxy is projection-only, so it
|
||||
* declares no compaction vocabulary; the cast writes the real event shape without
|
||||
* depending on the owning package.
|
||||
*/
|
||||
function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
|
||||
return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
|
||||
}
|
||||
|
||||
async function harness(): Promise<{ ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -207,6 +236,55 @@ describe('mux live view computation', () => {
|
||||
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const first = appendUserText(session, 'first prompt')
|
||||
appendAssistantText(session, 'first reply', 1)
|
||||
const third = appendUserText(session, 'second prompt')
|
||||
appendAssistantText(session, 'second reply', 2)
|
||||
const shadowed = [...session.surface.nodes]
|
||||
// A compaction transaction: log-only provenance immediately followed by the
|
||||
// replacement that shadows the range.
|
||||
const summary = appendExtension(session, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
|
||||
shadowedSeqs: shadowed,
|
||||
shadowedTokenCount: 0,
|
||||
provider: 'p',
|
||||
model: 'm',
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
|
||||
sourceEventSeqs: [...shadowed, summary.seq],
|
||||
})
|
||||
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('t-hist-compact'),
|
||||
payload: { sessionId: session.id, maxMessages: 2 },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const page = response.result.value.events.map(entry => entry.event)
|
||||
// Two append-origin messages fill the page even though a replacement copy of
|
||||
// the same event type sits in the window: the copy is model-only.
|
||||
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
|
||||
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
|
||||
expect(page.some(event => event.seq === first.seq)).toBe(false)
|
||||
expect(response.result.value.hasMore).toBe(true)
|
||||
// The range stays contiguous, so the checkpoint's provenance is readable on
|
||||
// the same page as the checkpoint itself.
|
||||
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
|
||||
expect(summaryIndex).toBeGreaterThan(-1)
|
||||
expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
|
||||
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
|
||||
})
|
||||
|
||||
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
@@ -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/ui/tui/README.md
|
||||
README.md: dc8af7796d62ca1588423dc63aa592fd3308d218
|
||||
README.zh.md: 8e5fb632d8903c1915015396af20a791a9a3ab70
|
||||
README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
|
||||
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d
|
||||
|
||||
@@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
|
||||
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
|
||||
|
||||
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend
|
||||
|
||||
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
|
||||
|
||||
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。
|
||||
TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。
|
||||
|
||||
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
@@ -74,6 +75,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Zero-state helpers for the interactive chat channel: prompt-directory and
|
||||
* Git-branch formatting, surface/tool-call derivations over the session log,
|
||||
* Git-branch formatting, transcript/tool-call derivations over the session log,
|
||||
* session-reference context cards, the placeholder editor, and banner-reveal
|
||||
* timing constants. None of these close over channel state.
|
||||
* @module @deepseek-ai/dsh-tui/chat/helpers
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
|
||||
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/** Editor that shows a placeholder without making it editable content. */
|
||||
@@ -81,24 +83,16 @@ export function gitBranch(cwd: string): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence numbers currently visible on the session surface.
|
||||
* @param session - session whose surface nodes to read.
|
||||
* @returns the set of visible event sequence numbers.
|
||||
*/
|
||||
export function activeSurfaceSeqs(session: Session): Set<number> {
|
||||
return new Set(session.surface.nodes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-call ids whose owning assistant message is on the active surface.
|
||||
* Tool-call ids whose owning assistant message is append-origin, so its tool
|
||||
* cards stay paired in the transcript after a replacement shadowed the message
|
||||
* on the model surface.
|
||||
* @param session - session whose events to scan.
|
||||
* @param active - sequence numbers currently on the surface.
|
||||
* @returns the set of active tool-call ids.
|
||||
* @returns the set of transcript tool-call ids.
|
||||
*/
|
||||
export function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
|
||||
export function transcriptToolCallIds(session: Session): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const event of session.events) {
|
||||
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
|
||||
if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) continue
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'tool-call') ids.add(block.id)
|
||||
}
|
||||
@@ -106,6 +100,26 @@ export function activeToolCallIds(session: Session, active: ReadonlySet<number>)
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event is a landed compaction checkpoint. Recognition goes through
|
||||
* {@link isCompactCheckpointSource} — the compaction seam's backend-independent
|
||||
* contract for the source every backend stamps on its replacement user message —
|
||||
* rather than the shape of the replacement. Other replacements (a pruned
|
||||
* `tool/result`, a regenerated `assistant/message`) rewrite one node for the
|
||||
* model and mark no boundary in the conversation.
|
||||
*
|
||||
* Both current call sites already test the replacement themselves. The check
|
||||
* keeps the exported predicate true to its name for a third caller, rather than
|
||||
* making that caller repeat it.
|
||||
* @param event - event to test.
|
||||
* @returns true when the event compacted a surface range.
|
||||
*/
|
||||
export function isCompactCheckpoint(event: SessionEvent): boolean {
|
||||
return event.type === 'user/message'
|
||||
&& isCompactCheckpointSource(event.data.source)
|
||||
&& isReplacementSurfaceEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a session-reference context card's display labels from an event source.
|
||||
* @param source - event source to inspect.
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
isReplacementSurfaceEvent,
|
||||
lastActivityTime,
|
||||
SessionId,
|
||||
type SessionEvent,
|
||||
@@ -120,14 +121,14 @@ import {
|
||||
} from './chat/skill-invocation.ts'
|
||||
import { ReferenceAutocompleteProvider } from './chat/autocomplete.ts'
|
||||
import {
|
||||
activeSurfaceSeqs,
|
||||
activeToolCallIds,
|
||||
BANNER_REVEAL_INTERVAL_MS,
|
||||
BANNER_REVEAL_STEPS,
|
||||
formatCwd,
|
||||
gitBranch,
|
||||
HintEditor,
|
||||
isCompactCheckpoint,
|
||||
sessionReferenceCard,
|
||||
transcriptToolCallIds,
|
||||
} from './chat/helpers.ts'
|
||||
import {
|
||||
createModelController,
|
||||
@@ -261,6 +262,13 @@ export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'too
|
||||
/** Model guidance for path-only file references selected through the TUI. */
|
||||
export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
|
||||
|
||||
/**
|
||||
* Transcript row standing in for one compacted range. The conversation the
|
||||
* compaction replaced stays rendered above it: the marker reports where the
|
||||
* model stopped seeing that history, not that the history is gone.
|
||||
*/
|
||||
const COMPACTION_MARKER = '… earlier context was compacted …'
|
||||
|
||||
interface RunningStatus {
|
||||
turn: number | undefined
|
||||
timer: ReturnType<typeof setInterval>
|
||||
@@ -808,6 +816,23 @@ export function createTuiChat(
|
||||
}
|
||||
}
|
||||
|
||||
const renderCompactionMarker = (): void => {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(COMPACTION_MARKER), 0, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay the human transcript from the append-only log. The model-visible
|
||||
* surface shadows compacted ranges, so it is not the source here: every
|
||||
* append-origin message stays rendered, and a replacement contributes at most
|
||||
* the compaction marker at its own log position.
|
||||
*
|
||||
* The `tool/call` pairing check has no live counterpart, because only replay
|
||||
* can meet an orphan: `tool/call` carries no `surfaceOp` of its own, so it
|
||||
* inherits transcript membership from the `assistant/message` that advertised
|
||||
* it, which the live listener has necessarily just rendered. A loaded log is a
|
||||
* replay boundary, so the pairing is re-derived here instead of assumed.
|
||||
*/
|
||||
const rebuildTranscript = (populateHistory: boolean): void => {
|
||||
chat.clear()
|
||||
toolCards.clear()
|
||||
@@ -815,15 +840,13 @@ export function createTuiChat(
|
||||
contextCards.clear()
|
||||
streaming = undefined
|
||||
todo.update([])
|
||||
const active = activeSurfaceSeqs(agent.session)
|
||||
const activeCalls = activeToolCallIds(agent.session, active)
|
||||
const transcriptCalls = transcriptToolCallIds(agent.session)
|
||||
for (const event of agent.session.events) {
|
||||
const isSurface = event.type === 'user/message'
|
||||
|| event.type === 'assistant/message'
|
||||
|| event.type === 'tool/result'
|
||||
|| event.type === 'steering/message'
|
||||
if (isSurface && !active.has(event.seq)) continue
|
||||
if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
|
||||
if (isReplacementSurfaceEvent(event)) {
|
||||
if (isCompactCheckpoint(event)) renderCompactionMarker()
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call' && !transcriptCalls.has(event.data.callId)) continue
|
||||
renderEvent(event, { addHistory: populateHistory, renderChunks: false })
|
||||
}
|
||||
requestRender()
|
||||
@@ -1475,8 +1498,11 @@ export function createTuiChat(
|
||||
recordEventUsage(tokens, event)
|
||||
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
|
||||
if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
|
||||
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
|
||||
rebuildTranscript(false)
|
||||
// A replacement mutates only the model surface, so the rendered transcript
|
||||
// keeps what it already showed; a landed summary checkpoint adds its marker.
|
||||
if (isReplacementSurfaceEvent(event)) {
|
||||
if (isCompactCheckpoint(event)) renderCompactionMarker()
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
renderEvent(event, { addHistory: false, renderChunks: true })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 44x18 buffer=normal length=18 base=0 viewport=0
|
||||
terminal 44x18 buffer=normal length=24 base=6 viewport=6
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=14 bufferRow=14
|
||||
cursor hidden column=7 viewportRow=17 bufferRow=23
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -13,25 +13,39 @@ buffer
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Old prompt with a long line that exercises "
|
||||
8| "wrapping and stays visible after compaction."
|
||||
9| <blank>
|
||||
10| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
11| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
12| "/workspace/project "
|
||||
style 0-17 dim
|
||||
13| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
14| "… +1 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
15| "1 test skipped "
|
||||
style 0-13 dim
|
||||
16| "coverage complete "
|
||||
style 0-16 dim
|
||||
17| "[exit 0] "
|
||||
style 0-7 dim
|
||||
18| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "Context · workspace-context"
|
||||
style 0-26 dim
|
||||
8| "Additional instructions from: "
|
||||
style 0-43 dim
|
||||
9| "nested/AGENTS.md "
|
||||
style 0-15 dim
|
||||
10| " "
|
||||
11| "Render workspace context XML clearly. "
|
||||
style 0-36 dim
|
||||
12| <blank>
|
||||
13| "/workspace/project (tui-staging) deepseek-v"
|
||||
19| <blank>
|
||||
20| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
21| <blank>
|
||||
22| "/workspace/project (tui-staging) deepseek-v"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-43 dim
|
||||
14| " dsh > "
|
||||
23| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
15-17| <blank>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 104x30 buffer=normal length=30 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=13 bufferRow=13
|
||||
cursor hidden column=7 viewportRow=22 bufferRow=22
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -13,25 +13,41 @@ buffer
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
|
||||
8| <blank>
|
||||
9| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
10| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
11| "/workspace/project "
|
||||
style 0-17 dim
|
||||
12| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
13| "… +1 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
14| "1 test skipped "
|
||||
style 0-13 dim
|
||||
15| "coverage complete "
|
||||
style 0-16 dim
|
||||
16| "[exit 0] "
|
||||
style 0-7 dim
|
||||
17| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "Context · workspace-context"
|
||||
style 0-26 dim
|
||||
8| "Additional instructions from: nested/AGENTS.md "
|
||||
style 0-45 dim
|
||||
9| " "
|
||||
10| "Render workspace context XML clearly. "
|
||||
style 0-36 dim
|
||||
11| <blank>
|
||||
12| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
18| <blank>
|
||||
19| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
20| <blank>
|
||||
21| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
13| " dsh > "
|
||||
22| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
14-29| <blank>
|
||||
23-29| <blank>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 80x24 buffer=normal length=24 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=20 bufferRow=20
|
||||
cursor hidden column=7 viewportRow=21 bufferRow=21
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -16,35 +16,36 @@ buffer
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Old prompt with a long line that exercises wrapping before compaction. "
|
||||
8| <blank>
|
||||
9| "● Tool / bash / Run the coverage gate"
|
||||
7| "Old prompt with a long line that exercises wrapping and stays visible after "
|
||||
8| "compaction. "
|
||||
9| <blank>
|
||||
10| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
10| "$ pnpm run test:coverage "
|
||||
11| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
11| "/workspace/project "
|
||||
12| "/workspace/project "
|
||||
style 0-17 dim
|
||||
12| "packages/ui/tui 100% "
|
||||
13| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
13| "… +1 lines (Ctrl+O to expand) "
|
||||
14| "… +1 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
14| "1 test skipped "
|
||||
15| "1 test skipped "
|
||||
style 0-13 dim
|
||||
15| "coverage complete "
|
||||
16| "coverage complete "
|
||||
style 0-16 dim
|
||||
16| "[exit 0] "
|
||||
17| "[exit 0] "
|
||||
style 0-7 dim
|
||||
17| "Model wait 0.0s "
|
||||
18| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
18| <blank>
|
||||
19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
19| <blank>
|
||||
20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
20| " dsh > "
|
||||
21| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
21-23| <blank>
|
||||
22-23| <blank>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
terminal 104x30 buffer=normal length=30 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=22 bufferRow=22
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
|
||||
8| <blank>
|
||||
9| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
10| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
11| "/workspace/project "
|
||||
style 0-17 dim
|
||||
12| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
13| "… +1 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
14| "1 test skipped "
|
||||
style 0-13 dim
|
||||
15| "coverage complete "
|
||||
style 0-16 dim
|
||||
16| "[exit 0] "
|
||||
style 0-7 dim
|
||||
17| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
18| <blank>
|
||||
19| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
20| <blank>
|
||||
21| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
22| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
23-29| <blank>
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
|
||||
import { createUserMessage, CallId, type ContentBlock , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type JsonValue, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -50,6 +51,7 @@ const CHECKPOINTS = [
|
||||
'surface-before-compaction',
|
||||
'surface-after-compaction-narrow',
|
||||
'surface-after-compaction-wide',
|
||||
'surface-replayed-compaction',
|
||||
'model-selector',
|
||||
'model-selector-filtered',
|
||||
'model-switching',
|
||||
@@ -181,6 +183,67 @@ function appendToolResult(
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Frozen clock for the compaction fixtures; see the live scenario for why. */
|
||||
const COMPACTION_FIXTURE_TIME = new Date(2026, 6, 21, 14, 40, 0).getTime()
|
||||
|
||||
/** The surface range a compaction checkpoint replaces, with its provenance. */
|
||||
interface CompactionRange {
|
||||
start: number
|
||||
end: number
|
||||
sources: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one prompt / tool-call / tool-result step, the history a compaction
|
||||
* shadows on the model surface and the transcript must keep showing. The prompt
|
||||
* text is rendered verbatim; the tool card's body comes from `bash`'s static
|
||||
* presenter, so the fixtures pin that the shadowed step's card survives rather
|
||||
* than the result content below.
|
||||
*/
|
||||
function appendPreCompactionLog(session: Session): CompactionRange {
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping and stays visible after compaction.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const assistant = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('old-tool'),
|
||||
content: [{ type: 'text', text: 'shadowed step tool output' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
return { start: user.seq, end: result.seq, sources: [user.seq, assistant.seq, result.seq] }
|
||||
}
|
||||
|
||||
/** Land a compaction: replace the range with the framed model-only checkpoint. */
|
||||
function appendCompactionCheckpoint(session: Session, range: CompactionRange): void {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<context_checkpoint>\nModel-only summary payload that must never reach the transcript.\n</context_checkpoint>',
|
||||
}],
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: range.start, end: range.end },
|
||||
sourceEventSeqs: range.sources,
|
||||
})
|
||||
}
|
||||
|
||||
function visualTool(
|
||||
name: string,
|
||||
call: NonNullable<ToolDefinition['presentCall']>,
|
||||
@@ -684,61 +747,22 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins compaction surface replacement and narrow-to-wide reflow', async () => {
|
||||
it('pins preserved history, the compaction marker, and narrow-to-wide reflow', async () => {
|
||||
// Freeze the clock: the timing header hides zero-duration buckets, so a
|
||||
// real-clock millisecond tick between the fixture appends and the render
|
||||
// would flip `Tools 0.0s` in and out of the pinned header.
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 40, 0).getTime())
|
||||
let replacementStart = 0
|
||||
let replacementEnd = 0
|
||||
let replacementSources: number[] = []
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME)
|
||||
// The awaited setup always invokes beforeMount, so the range the checkpoint
|
||||
// replaces is assigned by the time the appends below need it.
|
||||
let compacted!: CompactionRange
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
beforeMount(session) {
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping before compaction.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const assistant = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('old-tool'),
|
||||
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
replacementStart = user.seq
|
||||
replacementEnd = result.seq
|
||||
replacementSources = [user.seq, assistant.seq, result.seq]
|
||||
},
|
||||
beforeMount(session) { compacted = appendPreCompactionLog(session) },
|
||||
}, { columns: 80, rows: 24 })
|
||||
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => {
|
||||
harness.session.append('user/message', createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n</system-reminder>',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
|
||||
sourceEventSeqs: replacementSources,
|
||||
})
|
||||
appendCompactionCheckpoint(harness.session, compacted)
|
||||
harness.terminal.resize(44, 18)
|
||||
})
|
||||
await checkpoint('surface-after-compaction-narrow', harness.terminal, { includeScrollback: true })
|
||||
@@ -749,6 +773,23 @@ describe('TUI terminal-state snapshots', () => {
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
// The resume path, which is what regressed for real users: the replacement is
|
||||
// already stored when the terminal mounts, so the transcript comes from replay
|
||||
// rather than from live appends. Pinned against the same log the live scenario
|
||||
// ends on, at its wide size, so the two fixtures are directly comparable.
|
||||
it('pins a stored compaction replayed at mount', async () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME)
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
beforeMount(session) {
|
||||
appendCompactionCheckpoint(session, appendPreCompactionLog(session))
|
||||
},
|
||||
}, { columns: 104, rows: 30 })
|
||||
await checkpoint('surface-replayed-compaction', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('pins wrapped and explicit multiline shell-prompt input', async () => {
|
||||
const harness = await setupSnapshot({}, { columns: 44, rows: 18 })
|
||||
await renderAfter(harness, () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createUserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
|
||||
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
@@ -4661,10 +4662,10 @@ describe('tool cards and surface replay', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
|
||||
it('keeps append-origin history and marks a landed compaction, live and on rebuild', async () => {
|
||||
const result = await setup({ tools })
|
||||
appendUser(result.session, 'old prompt')
|
||||
const assistant = result.session.append('assistant/message', {
|
||||
result.session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
@@ -4687,21 +4688,116 @@ describe('tool cards and surface replay', () => {
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
const start = result.session.surface.nodes[0] as number
|
||||
result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary replacement' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start, end: toolResult.seq },
|
||||
sourceEventSeqs: [start, assistant.seq, toolResult.seq],
|
||||
// Result pruning rewrites one node's content in place: model-only, and no
|
||||
// boundary in the conversation, so the terminal keeps the full output.
|
||||
const originalResult = toolResult.data.message.content[0]
|
||||
result.session.append('tool/result', {
|
||||
...toolResult.data,
|
||||
message: freezeMessage({
|
||||
...toolResult.data.message,
|
||||
content: [{ ...originalResult, content: [{ type: 'text', text: 'pruned result copy' }] }] as [typeof originalResult],
|
||||
}),
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: toolResult.seq, end: toolResult.seq },
|
||||
sourceEventSeqs: [toolResult.seq],
|
||||
})
|
||||
const nodes = [...result.session.surface.nodes]
|
||||
const checkpoint = result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>model-only summary payload</context_checkpoint>' }],
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number },
|
||||
sourceEventSeqs: nodes,
|
||||
})
|
||||
// A regenerated assistant message replaces one node without summarizing
|
||||
// anything, so it marks no boundary either.
|
||||
const generic = result.session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'generic replacement copy' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: { op: 'replace', start: checkpoint.seq, end: checkpoint.seq }, sourceEventSeqs: [checkpoint.seq] })
|
||||
// Only a checkpoint carrying the compaction seam's source marks a boundary:
|
||||
// another plugin replacing a node is model-only.
|
||||
result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'foreign plugin replacement copy' }],
|
||||
source: { kind: 'plugin', plugin: 'other' },
|
||||
}), { surfaceOp: { op: 'replace', start: generic.seq, end: generic.seq }, sourceEventSeqs: [generic.seq] })
|
||||
await tick()
|
||||
|
||||
result.terminal.resize(89)
|
||||
await tick()
|
||||
const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(lastFullRender).toContain('summary replacement')
|
||||
expect(lastFullRender).not.toContain('old output')
|
||||
const liveRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(liveRender).toContain('old prompt')
|
||||
// The shadowed step keeps its card: one call row, one full result, no
|
||||
// second card from the pruned copy.
|
||||
expect(liveRender.split('$ printf hello')).toHaveLength(2)
|
||||
expect(liveRender).toContain('third')
|
||||
expect(liveRender.split('[exit 0]')).toHaveLength(2)
|
||||
expect(liveRender.split('… earlier context was compacted …')).toHaveLength(2)
|
||||
expect(liveRender).not.toContain('model-only summary payload')
|
||||
expect(liveRender).not.toContain('generic replacement copy')
|
||||
expect(liveRender).not.toContain('foreign plugin replacement copy')
|
||||
|
||||
// Ctrl+R toggles reasoning, which rebuilds the transcript from the log; the
|
||||
// replayed projection matches what the live appends produced, including the
|
||||
// shadowed assistant message's tool card.
|
||||
result.terminal.send('\x12')
|
||||
await tick()
|
||||
result.terminal.resize(90)
|
||||
await tick()
|
||||
const replayRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(replayRender).toContain('old prompt')
|
||||
expect(replayRender.split('$ printf hello')).toHaveLength(2)
|
||||
expect(replayRender).toContain('third')
|
||||
expect(replayRender.split('[exit 0]')).toHaveLength(2)
|
||||
expect(replayRender.split('… earlier context was compacted …')).toHaveLength(2)
|
||||
expect(replayRender).not.toContain('model-only summary payload')
|
||||
expect(replayRender).not.toContain('generic replacement copy')
|
||||
expect(replayRender).not.toContain('foreign plugin replacement copy')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('replays a stored compaction as preserved history plus its marker', async () => {
|
||||
const result = await setup({
|
||||
beforeMount(session) {
|
||||
appendUser(session, 'prompt before compaction')
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'reply before compaction' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
const nodes = [...session.surface.nodes]
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>stored model-only payload</context_checkpoint>' }],
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number },
|
||||
sourceEventSeqs: nodes,
|
||||
})
|
||||
},
|
||||
})
|
||||
result.terminal.resize(89)
|
||||
await tick()
|
||||
|
||||
const mounted = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(mounted).toContain('prompt before compaction')
|
||||
expect(mounted).toContain('reply before compaction')
|
||||
expect(mounted.split('… earlier context was compacted …')).toHaveLength(2)
|
||||
expect(mounted).not.toContain('stored model-only payload')
|
||||
await dispose(result)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
{
|
||||
"path": "../commands"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user