Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md
#	docs/architecture.i18n.yaml
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/defensive-patterns.i18n.yaml
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/core/scope/src/scoped-events.generated.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal-session/src/index.ts
#	packages/goal/goal-session/tests/goal-session.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/goal/tests/projection.spec.ts
#	packages/goal/tool-goal/tests/tool-goal.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
#	scripts/gen-cordis-catalog.ts
#	scripts/type-equiv.manifest.json
This commit is contained in:
_Kerman
2026-07-30 14:04:53 +08:00
1175 changed files with 49683 additions and 8452 deletions

View File

@@ -0,0 +1,44 @@
// Title-provenance invariant: messageSeqs is empty iff source.kind is 'user'
// — the durable relationship every appended session/title event must keep.
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(SessionTitleInvariantCompanion)
return ctx
}
describe('session-title provenance invariant', () => {
it('accepts cited automatic titles and citation-free user renames', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('title-invariant-valid'))
expect(() => {
session.append('session/title', { title: 'auto', messageSeqs: [1], source: { kind: 'fallback' } })
session.append('session/title', { title: 'named', messageSeqs: [], source: { kind: 'user' } })
}).not.toThrow()
})
it('rejects a citation-free automatic title and a user rename that cites messages', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('title-invariant-invalid'))
expect(() => {
session.append('session/title', { title: 'auto', messageSeqs: [], source: { kind: 'fallback' } })
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-session-title',
}))
expect(() => {
session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } })
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-session-title',
}))
expect(session.seq).toBe(0)
})
})

View File

@@ -0,0 +1,181 @@
// SessionTitleService.rename: user-source acceptance, normalization/rejection
// boundaries, and the pin (a user-sourced latest title schedules no automatic
// revision; explicit refresh stays the unpin).
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, {
SessionTitleProviderId,
foldSessionTitle,
type SessionTitleProviderRequest,
} from '@deepseek-ai/dsh-session-title'
const CONFIG = {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
maxTitleBytes: 40,
} as const
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
describe('SessionTitleService.rename', () => {
it('appends a normalized user-source title', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-accept'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, 'Original prompt text')
await settle()
const accepted = ctx.sessionTitle.rename(session, ' Hand\tpicked name ')
expect(accepted).toMatchObject({
title: 'Hand picked name',
messageSeqs: [],
source: { kind: 'user' },
})
const event = session.events.findLast(item => item.type === 'session/title')
expect(event?.data).toEqual({
title: 'Hand picked name',
messageSeqs: [],
source: { kind: 'user' },
})
// foldSessionTitle round-trips the third source kind.
expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' })
})
it('rejects titles that normalize to empty and dead sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-reject'))
expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/)
expect(() => ctx.sessionTitle.rename(new Session(SessionId('detached')), 'name'))
.toThrow(/not live in this store/)
})
it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({
title: 'Provider title',
messageSeqs: request.messages.map(message => message.seq),
}))
ctx.sessionTitle.register({
id: SessionTitleProviderId('pin-provider'),
automatic: 'all-user-messages',
generate,
})
const session = ctx.sessions.create(SessionId('rename-pin'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, 'First prompt')
await settle()
ctx.sessionTitle.rename(session, 'Pinned by hand')
// A later eligible prompt must schedule nothing while the pin stands.
appendHumanPrompt(session, 'Second prompt after the pin')
await settle()
session.append('request/header', {
header: { config: { provider: 'main-route', model: 'chat-model' } },
reason: 'change',
})
await settle()
expect(generate).not.toHaveBeenCalled()
expect(ctx.sessionTitle.get(session)?.title).toBe('Pinned by hand')
// Explicit refresh remains the deliberate unpin.
const refreshed = await ctx.sessionTitle.refresh(session)
expect(generate).toHaveBeenCalledOnce()
expect(refreshed?.title).toBe('Provider title')
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('provider')
})
it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-unpin-fallback'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, 'Derivable prompt words')
await settle()
ctx.sessionTitle.rename(session, 'Pinned without provider')
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
const refreshed = await ctx.sessionTitle.refresh(session)
expect(refreshed).toMatchObject({
title: 'Derivable prompt words',
source: { kind: 'fallback' },
})
// The pin is gone: the latest title is fallback-sourced, so the
// onUserMessage pin check no longer skips scheduling.
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
})
it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
// The provider parks on a test-held deferred so rename lands while its
// generation is ACTIVE (not merely scheduled).
let releaseProvider: (() => void) | undefined
const gate = new Promise<void>((resolve) => { releaseProvider = resolve })
let aborted = false
const generate = vi.fn(async (request: SessionTitleProviderRequest) => {
request.signal.addEventListener('abort', () => { aborted = true })
await gate
return { title: 'Late provider title', messageSeqs: request.messages.map(message => message.seq) }
})
ctx.sessionTitle.register({
id: SessionTitleProviderId('deferred-provider'),
automatic: 'all-user-messages',
generate,
})
const session = ctx.sessions.create(SessionId('rename-supersede'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, 'Prompt that triggers generation')
session.append('request/header', {
header: { config: { provider: 'main-route', model: 'chat-model' } },
reason: 'change',
})
await settle()
expect(generate).toHaveBeenCalledOnce()
ctx.sessionTitle.rename(session, 'User wins')
expect(aborted).toBe(true)
releaseProvider?.()
await settle()
// The released provider result must not append over the user title, and
// the swallowed abort must not surface as an unhandled rejection.
const latest = session.events.findLast(item => item.type === 'session/title')
expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } })
})
it('fallback-only refresh keeps the user title when no fallback is derivable', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
// A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the
// re-derived fallback is empty, so the pinned title survives the refresh.
await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 })
const session = ctx.sessions.create(SessionId('rename-unpin-empty'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, '😀😀')
await settle()
ctx.sessionTitle.rename(session, 'Sticky emoji pin')
const refreshed = await ctx.sessionTitle.refresh(session)
expect(refreshed?.title).toBe('Sticky emoji pin')
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
})
})