Drop prelaunch goal transcript compatibility

The /goal feature has not shipped, so the desktop and CLI paths should only understand the current set/clear protocol instead of carrying transitional transcript formats. This removes Goal created/replaced and old status-block parsing from hydration, WebSocket forwarding, and desktop history mapping.

Constraint: Feature is still pre-release, so historical Goal created/replaced transcript compatibility is unnecessary.

Rejected: Keep old status-block hydration | it preserves a surface that users never received and makes the simplified /goal protocol harder to reason about.

Confidence: high

Scope-risk: narrow

Directive: Do not re-add legacy /goal status-block parsing unless a shipped transcript format requires it.

Tested: bun test src/commands/goal/goal.test.tsx src/goals/goalState.test.ts src/goals/goalEvaluator.test.ts src/server/__tests__/ws-memory-events.test.ts src/server/__tests__/sessions.test.ts src/server/__tests__/conversations.test.ts

Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx src/pages/ActiveSession.test.tsx src/components/chat/composerUtils.test.ts src/__tests__/pages.test.tsx
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 12:39:27 +08:00
parent 818dc7a178
commit 174c6757f2
9 changed files with 31 additions and 277 deletions

View File

@ -169,8 +169,8 @@ describe('Content-only pages render without errors', () => {
})
expect(await screen.findAllByText('/goal')).toHaveLength(2)
expect(screen.getByText('[status|pause|resume|complete|clear|--tokens <budget>|<objective>]')).toBeInTheDocument()
expect(screen.getByText('Create or manage an autonomous completion goal')).toBeInTheDocument()
expect(screen.getByText('[<condition> | clear]')).toBeInTheDocument()
expect(screen.getByText('Set a completion goal')).toBeInTheDocument()
expect(screen.queryByText('/goal status')).not.toBeInTheDocument()
expect(screen.queryByText('/goal --tokens')).not.toBeInTheDocument()
})

View File

@ -282,7 +282,7 @@ describe('chatStore history mapping', () => {
])
})
it('restores replacement /goal output as a replacement event', () => {
it('restores repeated /goal set output as the current created event', () => {
const messages: MessageEntry[] = [
{
id: 'goal-command',
@ -294,16 +294,7 @@ describe('chatStore history mapping', () => {
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
'<local-command-stdout>',
'Goal replaced.',
'Goal: active',
'Objective: ship the replacement target',
'Budget: 0 / unlimited tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].join('\n'),
content: '<local-command-stdout>Goal set: ship the replacement target</local-command-stdout>',
},
]
@ -316,10 +307,9 @@ describe('chatStore history mapping', () => {
{
id: 'goal-output',
type: 'goal_event',
action: 'replaced',
action: 'created',
status: 'active',
objective: 'ship the replacement target',
budget: '0 / unlimited tokens',
},
])
})
@ -337,16 +327,7 @@ describe('chatStore history mapping', () => {
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship the smoke test',
'Budget: 0 / 2,000 tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].join('\n'),
content: '<local-command-stdout>Goal set: ship the smoke test</local-command-stdout>',
},
{
id: 'goal-complete',
@ -354,18 +335,6 @@ describe('chatStore history mapping', () => {
timestamp: '2026-04-06T00:00:02.000Z',
content: '<local-command-stdout>Goal marked complete.</local-command-stdout>',
},
{
id: 'goal-status-command',
type: 'system',
timestamp: '2026-04-06T00:00:03.000Z',
content: '<command-name>/goal</command-name>\n<command-args>status</command-args>',
},
{
id: 'goal-stale-empty-output',
type: 'system',
timestamp: '2026-04-06T00:00:04.000Z',
content: '<local-command-stdout>No active goal.</local-command-stdout>',
},
],
})
@ -395,17 +364,6 @@ describe('chatStore history mapping', () => {
action: 'completed',
message: 'Goal marked complete.',
},
{
id: 'goal-status-command',
type: 'user_text',
content: '/goal status',
},
{
id: 'goal-stale-empty-output',
type: 'goal_event',
action: 'message',
message: 'No active goal.',
},
])
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
action: 'completed',
@ -1329,7 +1287,7 @@ describe('chatStore history mapping', () => {
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'goal_event',
message: 'Goal: active',
message: 'Goal set: ship the smoke test',
data: {
action: 'created',
status: 'active',
@ -1360,9 +1318,9 @@ describe('chatStore history mapping', () => {
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'goal_event',
message: 'Goal replaced.',
message: 'Goal set: ship the replacement target',
data: {
action: 'replaced',
action: 'created',
status: 'active',
objective: 'ship the replacement target',
budget: '0 / unlimited tokens',
@ -1371,7 +1329,7 @@ describe('chatStore history mapping', () => {
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
action: 'replaced',
action: 'created',
status: 'active',
objective: 'ship the replacement target',
budget: '0 / unlimited tokens',

View File

@ -1048,7 +1048,6 @@ type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; n
type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string }
const TASK_NOTIFICATION_RE = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i
const GOAL_CLEAR_ALIASES = new Set(['clear'])
const GOAL_EVENT_ACTIONS = new Set<GoalEventAction>([
'created',
'replaced',
@ -1211,7 +1210,7 @@ function parseGoalEventFromLocalCommandOutput(
if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) return { action: 'cleared', message: trimmed }
if (trimmed === 'Goal marked complete.') return { action: 'completed', message: trimmed }
if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') return { action: 'message', message: trimmed }
if (trimmed === 'No active goal.') return { action: 'message', message: trimmed }
if (trimmed.startsWith('Goal set:')) {
const objective = trimmed.slice('Goal set:'.length).trim()
return {
@ -1222,52 +1221,7 @@ function parseGoalEventFromLocalCommandOutput(
}
}
const actionPrefix = trimmed.startsWith('Goal replaced.\n')
? 'replaced'
: trimmed.startsWith('Goal created.\n')
? 'created'
: null
const body = actionPrefix
? trimmed.split(/\r?\n/).slice(1).join('\n').trim()
: trimmed
const fields = Object.fromEntries(
body
.split(/\r?\n/)
.map((line) => {
const index = line.indexOf(':')
if (index < 0) return null
return [line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()]
})
.filter((entry): entry is [string, string] => entry !== null),
)
if (!fields.goal) return command?.name === 'goal' ? { action: 'message', message: trimmed } : null
return {
action: actionPrefix ?? resolveHistoryGoalAction(command, fields.goal),
status: fields.goal,
objective: fields.objective,
budget: fields.budget,
elapsed: fields.elapsed,
continuations: fields.continuations,
message: trimmed,
}
}
function resolveHistoryGoalAction(
command: { name: string; args: string } | null,
status: string,
): GoalEventAction {
const args = command?.args.trim() ?? ''
if (args === 'pause') return 'paused'
if (args === 'resume') return 'resumed'
if (GOAL_CLEAR_ALIASES.has(args)) return 'cleared'
if (args === 'complete') return 'completed'
if (!args || args === 'status') return 'status'
if (status === 'active') return 'created'
if (status === 'paused') return 'paused'
if (status === 'complete') return 'completed'
return 'status'
return command?.name === 'goal' ? { action: 'message', message: trimmed } : null
}
function extractTaskNotification(content: unknown): AgentTaskNotification | null {

View File

@ -90,28 +90,14 @@ describe('/goal command', () => {
].join('\n')),
createCommandInputMessage([
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship persisted goal',
'Budget: 42 / 2,000 tokens',
'Elapsed: 1m',
'Continuations: 3',
'Goal set: ship persisted goal',
'</local-command-stdout>',
].join('\n')),
createCommandInputMessage([
'<local-command-stdout>',
'Goal: complete',
'Objective: ship persisted goal',
'Budget: 1,234 / 2,000 tokens',
'Elapsed: 23m',
'Continuations: 2',
'Goal marked complete.',
'</local-command-stdout>',
].join('\n')),
createCommandInputMessage([
'<command-name>/goal</command-name>',
'<command-args>status</command-args>',
].join('\n')),
createCommandInputMessage('<local-command-stdout>No active goal.</local-command-stdout>'),
],
})

View File

@ -149,12 +149,7 @@ describe('goalEvaluator', () => {
createUserMessage({
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship persisted goal',
'Budget: 42 / 2,000 tokens',
'Elapsed: 1m',
'Continuations: 3',
'Goal set: ship persisted goal',
'</local-command-stdout>',
].join('\n'),
}),
@ -174,8 +169,8 @@ describe('goalEvaluator', () => {
expect(decision.action).toBe('continue')
const restored = getThreadGoal(threadId)
expect(restored?.objective).toBe('ship persisted goal')
expect(restored?.tokensUsed).toBe(42)
expect(restored?.tokenBudget).toBe(2_000)
expect(restored?.continuationCount).toBe(4)
expect(restored?.tokensUsed).toBe(0)
expect(restored?.tokenBudget).toBeNull()
expect(restored?.continuationCount).toBe(1)
})
})

View File

@ -220,7 +220,7 @@ function goalFromLocalCommandOutput(
if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) {
return null
}
if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') return current
if (trimmed === 'No active goal.') return current
if (trimmed === 'Goal marked complete.') {
return current ? { ...current, status: 'complete', updatedAt: now } : null
}
@ -241,27 +241,7 @@ function goalFromLocalCommandOutput(
}
}
const body = trimmed.startsWith('Goal created.\n') || trimmed.startsWith('Goal replaced.\n')
? trimmed.split(/\r?\n/).slice(1).join('\n').trim()
: trimmed
const fields = parseStatusFields(body)
const status = parseGoalStatus(fields.goal)
if (!status || !fields.objective) return current
const budget = parseBudget(fields.budget)
const elapsedMs = parseElapsed(fields.elapsed)
return {
goalId: randomUUID(),
threadId,
objective: fields.objective,
status,
tokenBudget: budget.tokenBudget,
tokensUsed: budget.tokensUsed,
continuationCount: parseInteger(fields.continuations) ?? 0,
lastReason: fields['latest reason'] ?? null,
createdAt: now - elapsedMs,
updatedAt: now,
}
return current
}
function messageToText(message: Message): string {
@ -291,70 +271,10 @@ function readXmlTag(text: string, tag: string): string | null {
function looksLikeGoalStatusOutput(output: string): boolean {
const trimmed = output.trim()
return (
trimmed.startsWith('Goal created.\n') ||
trimmed.startsWith('Goal replaced.\n') ||
trimmed.startsWith('Goal set:') ||
trimmed.startsWith('Goal: ') ||
trimmed === 'Goal cleared.' ||
trimmed.startsWith('Goal cleared:') ||
trimmed === 'Goal marked complete.'
trimmed === 'Goal marked complete.' ||
trimmed === 'No active goal.'
)
}
function parseStatusFields(output: string): Record<string, string> {
return Object.fromEntries(
output
.split(/\r?\n/)
.map((line) => {
const index = line.indexOf(':')
if (index < 0) return null
return [line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()]
})
.filter((entry): entry is [string, string] => entry !== null),
)
}
function parseGoalStatus(raw: string | undefined): ThreadGoalStatus | null {
if (
raw === 'active' ||
raw === 'paused' ||
raw === 'complete' ||
raw === 'budget_limited'
) {
return raw
}
return null
}
function parseBudget(raw: string | undefined): {
tokenBudget: number | null
tokensUsed: number
} {
if (!raw) return { tokenBudget: null, tokensUsed: 0 }
const match = raw.match(/^([\d,]+)\s*\/\s*(unlimited|[\d,]+)\s+tokens$/i)
if (!match) return { tokenBudget: null, tokensUsed: 0 }
return {
tokensUsed: parseInteger(match[1]) ?? 0,
tokenBudget: match[2]?.toLowerCase() === 'unlimited'
? null
: parseInteger(match[2]) ?? null,
}
}
function parseElapsed(raw: string | undefined): number {
if (!raw) return 0
let ms = 0
for (const match of raw.matchAll(/(\d+)\s*([hms])/g)) {
const value = Number(match[1])
if (match[2] === 'h') ms += value * 60 * 60 * 1000
if (match[2] === 'm') ms += value * 60 * 1000
if (match[2] === 's') ms += value * 1000
}
return ms
}
function parseInteger(raw: string | undefined): number | null {
if (!raw) return null
const value = Number(raw.replace(/,/g, ''))
return Number.isFinite(value) ? value : null
}

View File

@ -758,16 +758,7 @@ describe('SessionService', () => {
isSidechain: false,
type: 'system',
subtype: 'local_command',
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship persisted goal',
'Budget: 0 / unlimited tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].join('\n'),
content: '<local-command-stdout>Goal set: ship persisted goal</local-command-stdout>',
level: 'info',
timestamp: '2026-01-01T00:00:02.000Z',
uuid: 'goal-output',
@ -786,7 +777,7 @@ describe('SessionService', () => {
{
id: 'goal-output',
type: 'system',
content: expect.stringContaining('Goal created.'),
content: expect.stringContaining('Goal set: ship persisted goal'),
},
{
type: 'assistant',

View File

@ -561,13 +561,11 @@ export class SessionService {
private isGoalLocalCommandOutput(output: string): boolean {
const trimmed = output.trim()
return (
trimmed.startsWith('Goal created.\n') ||
trimmed.startsWith('Goal replaced.\n') ||
trimmed.startsWith('Goal: ') ||
trimmed.startsWith('Goal set:') ||
trimmed.startsWith('Goal cleared:') ||
trimmed === 'Goal cleared.' ||
trimmed === 'Goal marked complete.' ||
trimmed === 'No active goal.' ||
trimmed === 'No goal to resume.'
trimmed === 'No active goal.'
)
}
@ -614,7 +612,7 @@ export class SessionService {
if (commandName !== 'goal') return null
const args = this.readXmlTag(entry.content, 'command-args')?.trim()
if (!args || /^(status|pause|resume|complete|clear)\b/i.test(args)) return null
if (!args || /^clear\b/i.test(args)) return null
const title = cleanSessionTitleSource(`/goal ${args}`)
return title ? title.length > 80 ? title.slice(0, 80) + '...' : title : null

View File

@ -1488,7 +1488,7 @@ function extractGoalEvent(
if (trimmed === 'Goal marked complete.') {
return { action: 'completed', message: trimmed }
}
if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') {
if (trimmed === 'No active goal.') {
return { action: 'message', message: trimmed }
}
@ -1502,37 +1502,7 @@ function extractGoalEvent(
}
}
const actionPrefix = trimmed.startsWith('Goal replaced.\n')
? 'replaced'
: trimmed.startsWith('Goal created.\n')
? 'created'
: null
const body = actionPrefix
? trimmed.split(/\r?\n/).slice(1).join('\n').trim()
: trimmed
const lines = Object.fromEntries(
body
.split(/\r?\n/)
.map((line) => {
const index = line.indexOf(':')
if (index < 0) return null
return [line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()]
})
.filter((entry): entry is [string, string] => entry !== null),
)
if (!lines.goal) return command?.name === 'goal' ? { action: 'message', message: trimmed } : null
return {
action: actionPrefix ?? resolveGoalEventAction(command, lines.goal),
status: lines.goal,
objective: lines.objective,
budget: lines.budget,
elapsed: lines.elapsed,
continuations: lines.continuations,
message: trimmed,
}
return command?.name === 'goal' ? { action: 'message', message: trimmed } : null
}
function looksLikeGoalCommandOutput(output: string): boolean {
@ -1542,28 +1512,10 @@ function looksLikeGoalCommandOutput(output: string): boolean {
trimmed.startsWith('Goal cleared:') ||
trimmed === 'Goal cleared.' ||
trimmed === 'Goal marked complete.' ||
trimmed === 'No active goal.' ||
trimmed === 'No goal to resume.' ||
trimmed.startsWith('Goal created.\n') ||
trimmed.startsWith('Goal replaced.\n') ||
trimmed.startsWith('Goal: ')
trimmed === 'No active goal.'
)
}
function resolveGoalEventAction(
command: { name: string; args: string } | undefined,
status: string,
): GoalEventData['action'] {
const args = command?.args.trim() ?? ''
if (args === 'pause') return 'paused'
if (args === 'resume') return 'resumed'
if (!args || args === 'status') return 'status'
if (status === 'paused') return 'paused'
if (status === 'complete') return 'completed'
if (status === 'active') return 'created'
return 'status'
}
function getCompactBoundaryMessage(cliMsg: any): string {
const message = typeof cliMsg?.message === 'string' ? cliMsg.message.trim() : ''
if (message) return message