mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
818dc7a178
commit
174c6757f2
@ -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()
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>'),
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user