Files
multica/server/pkg/agent/copilot_test.go
devv-eve 5fa1da448f fix(chat): preserve chat session resume pointer across failures (#1360)
* fix(chat): preserve chat session resume pointer across failures

The chat 'forgets earlier messages' bug came from PriorSessionID being
silently lost in several edge cases:

- UpdateChatSessionSession unconditionally overwrote chat_session.session_id,
  so any task that completed without a session_id (early agent crash,
  missing result) wiped the resume pointer to NULL.
- CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A
  follow-up chat message claimed in between resumed against a stale (or
  NULL) session and started over.
- FailAgentTask never wrote session_id back, so a task that established
  a real session before failing lost its resume pointer.
- ClaimTaskByRuntime only trusted chat_session.session_id and never
  fell back to the existing GetLastChatTaskSession query, so a single
  bad turn could permanently drop the conversation memory.

This change:

- Use COALESCE in UpdateChatSessionSession so empty inputs preserve the
  existing pointer; surface DB errors instead of swallowing them.
- Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside
  the same transaction (TaskService now takes a TxStarter).
- Extend FailAgentTask + the daemon FailTask path (client, handler,
  service) to forward session_id/work_dir, so failed/blocked tasks that
  built a real session still record it.
- Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the
  chat_session pointer is missing, and include failed tasks in that
  lookup so a single failure can't lose the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): forward session_id/work_dir on blocked + timeout paths

runTask previously dropped result.SessionID and env.WorkDir on the
non-completed return paths:

- timeout returned a naked error, so handleTask called FailTask with
  empty session info and the chat resume pointer was either left stale
  or eventually overwritten with NULL.
- blocked / failed (default branch) returned a TaskResult without
  SessionID / WorkDir, so even though FailTask now COALESCEs into
  chat_session, there was no value to write through.
- the empty-output completion path was the same: it raised an error
  even when a real session_id had been built.

All three paths now return a TaskResult that carries the SessionID /
WorkDir the backend produced. Combined with the COALESCE-based update
in UpdateChatSessionSession and the FailTask plumbing introduced in
PR #1360, the next chat turn can always resume from the latest agent
session — even when the previous turn timed out, was rate-limited, or
returned an empty completion — instead of starting over with no memory
of the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): capture session id from session.start as fallback

The Copilot backend only read sessionId from the synthetic 'result'
event, ignoring the one already present on session.start. When the CLI
was killed before result arrived (timeout, cancel, crash, or a
session.error mid-turn), the daemon reported SessionID="" and the
chat-session resume pointer could not advance — causing the chat to
silently drop conversation memory on the next turn.

Capture session.start.sessionId into state up front, and only let
'result' overwrite it when it actually carries one. result still wins
when present (it is the authoritative end-of-turn record).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): parse premiumRequests as float to preserve session id

Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5),
not an integer. Our copilotResultUsage struct typed it as int, which
made the entire 'result' line fail json.Unmarshal — silently dropping
sessionId on every turn.

This was the real cause of chat memory loss: the daemon reported
SessionID="" to the server, chat_session.session_id stayed NULL, and
the next chat turn never received --resume <id>, so each turn started
a fresh Copilot session with no prior context.

Add a regression test using the real JSON line from CLI v1.0.32 that
asserts sessionId is preserved when premiumRequests is fractional.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: yushen <ldnvnbl@gmail.com>
2026-04-19 22:50:33 -07:00

761 lines
26 KiB
Go

package agent
import (
"encoding/json"
"log/slog"
"strings"
"testing"
)
// ── Fixtures from real Copilot CLI v1.0.28 --output-format json output ──
const fixtureAssistantMessageDelta = `{"type":"assistant.message_delta","data":{"messageId":"b5148f3f-d24b-4a5e-a95c-2be7d6493a52","deltaContent":"pong"},"id":"eb6c3ef1-0388-4010-bf8e-4002b62db58c","timestamp":"2026-04-16T08:43:38.401Z","parentId":"417b175a-b303-4378-9c43-d4fcb177c05a","ephemeral":true}`
const fixtureAssistantMessage = `{"type":"assistant.message","data":{"messageId":"b5148f3f-d24b-4a5e-a95c-2be7d6493a52","content":"pong","toolRequests":[],"interactionId":"267266f6-47bc-4f31-8338-4e95961cf900","outputTokens":5,"requestId":"D012:2F8B66:8CB3C8:98A605:69E0A137"},"id":"ddff21bc-5829-4892-822a-06f3f543ea1d","timestamp":"2026-04-16T08:43:38.493Z","parentId":"417b175a-b303-4378-9c43-d4fcb177c05a"}`
const fixtureAssistantMessageWithTools = `{"type":"assistant.message","data":{"messageId":"0c48f3f5-74a2-485b-8969-3ea8ddc4c303","content":"","toolRequests":[{"toolCallId":"toolu_vrtx_01UqgJdCxuteCRZvKpdjUFyL","name":"bash","arguments":{"command":"ls","description":"List files"},"type":"function","intentionSummary":"List files in current directory"}],"interactionId":"b7bede2d-6996-4728-bdfa-33ba546ed511","outputTokens":112,"requestId":"EB94:21B867:8C33D3:983053:69E0A149"},"id":"6c005d04-bf23-4114-8dcb-f2f9bcdd3880","timestamp":"2026-04-16T08:43:59.066Z","parentId":"387c2814-f893-443c-82b8-00db66fef14c"}`
const fixtureToolExecComplete = `{"type":"tool.execution_complete","data":{"toolCallId":"toolu_vrtx_01UqgJdCxuteCRZvKpdjUFyL","model":"claude-opus-4.6","interactionId":"b7bede2d-6996-4728-bdfa-33ba546ed511","success":true,"result":{"content":"file1.go\nfile2.go\n","detailedContent":"file1.go\nfile2.go\n"},"toolTelemetry":{}},"id":"1662b7b1-5160-4c03-bc83-59a9a367f070","timestamp":"2026-04-16T08:43:59.530Z","parentId":"92531882-91ba-442a-9974-3dd8745fffd0"}`
const fixtureToolExecCompleteError = `{"type":"tool.execution_complete","data":{"toolCallId":"toolu_err_01","model":"claude-opus-4.6","interactionId":"int-1","success":false,"error":{"message":"command not found: foobar"}},"id":"err-1","timestamp":"2026-04-16T08:44:00.000Z","parentId":"p-1"}`
const fixtureTurnStart = `{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"267266f6-47bc-4f31-8338-4e95961cf900"},"id":"417b175a-b303-4378-9c43-d4fcb177c05a","timestamp":"2026-04-16T08:43:36.401Z","parentId":"ed1a637b-c636-4b74-bc82-4ba3f3386aad"}`
const fixtureResult = `{"type":"result","timestamp":"2026-04-16T08:43:38.524Z","sessionId":"35059dc3-d928-4ffb-8616-b78938621d85","exitCode":0,"usage":{"premiumRequests":3,"totalApiDurationMs":1763,"sessionDurationMs":6275,"codeChanges":{"linesAdded":0,"linesRemoved":0,"filesModified":[]}}}`
const fixtureResultNonZero = `{"type":"result","timestamp":"2026-04-16T08:50:00.000Z","sessionId":"dead-beef","exitCode":1,"usage":{"premiumRequests":1,"totalApiDurationMs":500,"sessionDurationMs":1000}}`
const fixtureSessionError = `{"type":"session.error","data":{"errorType":"rate_limit","message":"Rate limit exceeded"},"id":"se-1","timestamp":"2026-04-16T09:00:00.000Z","parentId":"p-1"}`
const fixtureEphemeral = `{"type":"session.mcp_servers_loaded","data":{"servers":[{"name":"github-mcp-server","status":"connected","source":"builtin"}]},"id":"330ac6bb-b2db-435e-8082-686face58a72","timestamp":"2026-04-16T08:43:34.803Z","parentId":"fe20d689-31ec-492c-9eb5-57a0d0834d70","ephemeral":true}`
const fixtureSessionStart = `{"type":"session.start","data":{"sessionId":"35059dc3-d928-4ffb-8616-b78938621d85","selectedModel":"claude-sonnet-4","context":{"cwd":"/tmp"}},"id":"ss-1","timestamp":"2026-04-16T08:43:34.000Z"}`
const fixtureReasoning = `{"type":"assistant.reasoning","data":{"content":"Let me think about this..."},"id":"r-1","timestamp":"2026-04-16T08:43:37.000Z","parentId":"p-1"}`
const fixtureReasoningDelta = `{"type":"assistant.reasoning_delta","data":{"deltaContent":"thinking step"},"id":"rd-1","timestamp":"2026-04-16T08:43:37.100Z","parentId":"p-1","ephemeral":true}`
const fixtureSessionWarning = `{"type":"session.warning","data":{"warningType":"rate_limit_approaching","message":"You are approaching your rate limit"},"id":"sw-1","timestamp":"2026-04-16T09:00:00.000Z","parentId":"p-1"}`
// parseCopilotEvent is a test helper that unmarshals a JSONL line into a copilotEvent.
func parseCopilotEvent(t *testing.T, line string) copilotEvent {
t.Helper()
var evt copilotEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
t.Fatalf("failed to parse fixture: %v", err)
}
return evt
}
// ── Parser tests using real JSONL fixtures ──
func TestCopilotParseAssistantMessageDelta(t *testing.T) {
t.Parallel()
evt := parseCopilotEvent(t, fixtureAssistantMessageDelta)
if evt.Type != "assistant.message_delta" {
t.Fatalf("expected type assistant.message_delta, got %q", evt.Type)
}
var delta copilotMessageDelta
if err := json.Unmarshal(evt.Data, &delta); err != nil {
t.Fatalf("unmarshal delta data: %v", err)
}
if delta.DeltaContent != "pong" {
t.Fatalf("expected deltaContent 'pong', got %q", delta.DeltaContent)
}
if delta.MessageID != "b5148f3f-d24b-4a5e-a95c-2be7d6493a52" {
t.Fatalf("unexpected messageId: %q", delta.MessageID)
}
}
func TestCopilotParseAssistantMessage(t *testing.T) {
t.Parallel()
evt := parseCopilotEvent(t, fixtureAssistantMessage)
if evt.Type != "assistant.message" {
t.Fatalf("expected type assistant.message, got %q", evt.Type)
}
var msg copilotAssistantMessage
if err := json.Unmarshal(evt.Data, &msg); err != nil {
t.Fatalf("unmarshal message data: %v", err)
}
if msg.Content != "pong" {
t.Fatalf("expected content 'pong', got %q", msg.Content)
}
if msg.OutputTokens != 5 {
t.Fatalf("expected outputTokens 5, got %d", msg.OutputTokens)
}
if len(msg.ToolRequests) != 0 {
t.Fatalf("expected no tool requests, got %d", len(msg.ToolRequests))
}
}
func TestCopilotParseAssistantMessageWithToolRequests(t *testing.T) {
t.Parallel()
evt := parseCopilotEvent(t, fixtureAssistantMessageWithTools)
var msg copilotAssistantMessage
if err := json.Unmarshal(evt.Data, &msg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(msg.ToolRequests) != 1 {
t.Fatalf("expected 1 tool request, got %d", len(msg.ToolRequests))
}
tr := msg.ToolRequests[0]
if tr.Name != "bash" {
t.Fatalf("expected tool name 'bash', got %q", tr.Name)
}
if tr.ToolCallID != "toolu_vrtx_01UqgJdCxuteCRZvKpdjUFyL" {
t.Fatalf("unexpected toolCallId: %q", tr.ToolCallID)
}
var args map[string]any
if err := json.Unmarshal(tr.Arguments, &args); err != nil {
t.Fatalf("unmarshal arguments: %v", err)
}
if args["command"] != "ls" {
t.Fatalf("expected command 'ls', got %v", args["command"])
}
}
func TestCopilotParseToolExecComplete(t *testing.T) {
t.Parallel()
evt := parseCopilotEvent(t, fixtureToolExecComplete)
if evt.Type != "tool.execution_complete" {
t.Fatalf("expected type tool.execution_complete, got %q", evt.Type)
}
var tc copilotToolExecComplete
if err := json.Unmarshal(evt.Data, &tc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !tc.Success {
t.Fatal("expected success=true")
}
if tc.Model != "claude-opus-4.6" {
t.Fatalf("expected model claude-opus-4.6, got %q", tc.Model)
}
if tc.Result == nil || tc.Result.Content != "file1.go\nfile2.go\n" {
t.Fatalf("unexpected result content: %v", tc.Result)
}
}
func TestCopilotParseToolExecCompleteError(t *testing.T) {
t.Parallel()
evt := parseCopilotEvent(t, fixtureToolExecCompleteError)
var tc copilotToolExecComplete
if err := json.Unmarshal(evt.Data, &tc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if tc.Success {
t.Fatal("expected success=false")
}
if tc.Error == nil || tc.Error.Message != "command not found: foobar" {
t.Fatalf("unexpected error: %v", tc.Error)
}
}
func TestCopilotParseResultFractionalPremiumRequests(t *testing.T) {
t.Parallel()
// Regression: real Copilot CLI v1.0.32 emits premiumRequests as a float
// (e.g. 7.5). Decoding into an int field used to fail the entire result
// line, dropping sessionId and breaking chat-session resume.
const line = `{"type":"result","timestamp":"2026-04-20T05:34:30.469Z","sessionId":"349793b7-7067-49d4-a807-8788561643bd","exitCode":0,"usage":{"premiumRequests":7.5,"totalApiDurationMs":1500,"sessionDurationMs":5842,"codeChanges":{"linesAdded":0,"linesRemoved":0,"filesModified":[]}}}`
var evt copilotEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if evt.SessionID != "349793b7-7067-49d4-a807-8788561643bd" {
t.Fatalf("unexpected sessionId: %q", evt.SessionID)
}
if evt.Usage == nil || evt.Usage.PremiumRequests != 7.5 {
t.Fatalf("expected premiumRequests=7.5, got %#v", evt.Usage)
}
}
func TestCopilotParseResult(t *testing.T) {
t.Parallel()
evt := parseCopilotEvent(t, fixtureResult)
if evt.Type != "result" {
t.Fatalf("expected type result, got %q", evt.Type)
}
if evt.SessionID != "35059dc3-d928-4ffb-8616-b78938621d85" {
t.Fatalf("unexpected sessionId: %q", evt.SessionID)
}
if evt.ExitCode != 0 {
t.Fatalf("expected exitCode 0, got %d", evt.ExitCode)
}
if evt.Usage == nil {
t.Fatal("expected usage to be present")
}
if evt.Usage.PremiumRequests != 3 {
t.Fatalf("expected 3 premiumRequests, got %v", evt.Usage.PremiumRequests)
}
if evt.Usage.TotalAPIDurationMs != 1763 {
t.Fatalf("expected totalApiDurationMs 1763, got %d", evt.Usage.TotalAPIDurationMs)
}
}
func TestCopilotParseResultNonZeroExit(t *testing.T) {
t.Parallel()
evt := parseCopilotEvent(t, fixtureResultNonZero)
if evt.ExitCode != 1 {
t.Fatalf("expected exitCode 1, got %d", evt.ExitCode)
}
if evt.SessionID != "dead-beef" {
t.Fatalf("unexpected sessionId: %q", evt.SessionID)
}
}
func TestCopilotParseSessionError(t *testing.T) {
t.Parallel()
evt := parseCopilotEvent(t, fixtureSessionError)
if evt.Type != "session.error" {
t.Fatalf("expected type session.error, got %q", evt.Type)
}
var se copilotSessionError
if err := json.Unmarshal(evt.Data, &se); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if se.ErrorType != "rate_limit" {
t.Fatalf("expected errorType rate_limit, got %q", se.ErrorType)
}
if se.Message != "Rate limit exceeded" {
t.Fatalf("unexpected message: %q", se.Message)
}
}
// ── Integration-style tests: feed fixture JSONL through the event loop ──
// simulateCopilotEventLoop feeds JSONL lines through handleCopilotEvent —
// the exact same function used in production — and collects the results.
func simulateCopilotEventLoop(t *testing.T, lines []string) ([]Message, string, string, map[string]TokenUsage) {
return simulateCopilotEventLoopWithModel(t, lines, "copilot")
}
func simulateCopilotEventLoopWithModel(t *testing.T, lines []string, seedModel string) ([]Message, string, string, map[string]TokenUsage) {
t.Helper()
var msgs []Message
st := newCopilotEventState(seedModel)
for _, line := range lines {
var evt copilotEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
continue
}
msgs = append(msgs, handleCopilotEvent(evt, st)...)
}
return msgs, st.sessionID, st.finalStatus, st.usage
}
func TestCopilotEventLoopSimpleMessage(t *testing.T) {
t.Parallel()
lines := []string{
fixtureTurnStart,
fixtureAssistantMessageDelta,
fixtureAssistantMessage,
`{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"fc387368","timestamp":"2026-04-16T08:43:38.494Z","parentId":"ddff21bc"}`,
fixtureResult,
}
msgs, sessionID, status, usage := simulateCopilotEventLoop(t, lines)
if sessionID != "35059dc3-d928-4ffb-8616-b78938621d85" {
t.Fatalf("unexpected sessionId: %q", sessionID)
}
if status != "completed" {
t.Fatalf("expected completed, got %q", status)
}
// Should have: turn_start(status), delta(text:pong), message doesn't re-emit text
var gotStatus, gotText bool
for _, m := range msgs {
if m.Type == MessageStatus && m.Status == "running" {
gotStatus = true
}
if m.Type == MessageText && m.Content == "pong" {
gotText = true
}
}
if !gotStatus {
t.Fatal("expected status=running message")
}
if !gotText {
t.Fatal("expected text=pong message")
}
u, ok := usage["copilot"]
if !ok {
t.Fatal("expected usage entry for 'copilot'")
}
if u.OutputTokens != 5 {
t.Fatalf("expected 5 outputTokens, got %d", u.OutputTokens)
}
}
func TestCopilotEventLoopToolUseFlow(t *testing.T) {
t.Parallel()
lines := []string{
fixtureTurnStart,
fixtureAssistantMessageWithTools,
fixtureToolExecComplete,
fixtureResult,
}
msgs, sessionID, status, usage := simulateCopilotEventLoop(t, lines)
if sessionID != "35059dc3-d928-4ffb-8616-b78938621d85" {
t.Fatalf("unexpected sessionId: %q", sessionID)
}
if status != "completed" {
t.Fatalf("expected completed, got %q", status)
}
// Find tool use and tool result messages.
var toolUse, toolResult *Message
for i := range msgs {
if msgs[i].Type == MessageToolUse {
toolUse = &msgs[i]
}
if msgs[i].Type == MessageToolResult {
toolResult = &msgs[i]
}
}
if toolUse == nil {
t.Fatal("expected MessageToolUse")
}
if toolUse.Tool != "bash" {
t.Fatalf("expected tool 'bash', got %q", toolUse.Tool)
}
if toolUse.CallID != "toolu_vrtx_01UqgJdCxuteCRZvKpdjUFyL" {
t.Fatalf("unexpected callID: %q", toolUse.CallID)
}
if toolResult == nil {
t.Fatal("expected MessageToolResult")
}
if toolResult.CallID != toolUse.CallID {
t.Fatalf("tool result callID %q doesn't match tool use callID %q", toolResult.CallID, toolUse.CallID)
}
if !strings.Contains(toolResult.Output, "file1.go") {
t.Fatalf("expected tool result to contain 'file1.go', got %q", toolResult.Output)
}
// After tool.execution_complete with model, activeModel should be updated.
if _, ok := usage["claude-opus-4.6"]; ok {
// outputTokens from assistant.message came BEFORE tool.execution_complete,
// so they should be under "copilot", not "claude-opus-4.6".
t.Log("model attribution is correct: assistant.message tokens go under initial model")
}
u := usage["copilot"]
if u.OutputTokens != 112 {
t.Fatalf("expected 112 outputTokens under 'copilot', got %d", u.OutputTokens)
}
}
func TestCopilotEventLoopToolExecError(t *testing.T) {
t.Parallel()
lines := []string{fixtureToolExecCompleteError}
msgs, _, _, _ := simulateCopilotEventLoop(t, lines)
var found bool
for _, m := range msgs {
if m.Type == MessageToolResult && strings.Contains(m.Output, "command not found: foobar") {
found = true
}
}
if !found {
t.Fatal("expected tool result with error message")
}
}
func TestCopilotEventLoopSessionStartCapturesSessionID(t *testing.T) {
t.Parallel()
// session.start arrives but the run is killed (timeout/cancel/crash) before
// the synthetic "result" line is emitted. We must still report the session
// id from session.start so the chat-session resume pointer can advance.
lines := []string{fixtureSessionStart}
_, sessionID, _, _ := simulateCopilotEventLoop(t, lines)
if sessionID != "35059dc3-d928-4ffb-8616-b78938621d85" {
t.Fatalf("expected session id captured from session.start, got %q", sessionID)
}
}
func TestCopilotEventLoopResultOverridesSessionStart(t *testing.T) {
t.Parallel()
// When both session.start and result carry a session id, the result event
// wins (it is the authoritative end-of-turn record).
lines := []string{
fixtureSessionStart,
// Different sessionId on the result event (defensive: in practice
// they should match, but the contract is "result wins").
`{"type":"result","sessionId":"final-id","exitCode":0}`,
}
_, sessionID, _, _ := simulateCopilotEventLoop(t, lines)
if sessionID != "final-id" {
t.Fatalf("expected result session id to win, got %q", sessionID)
}
}
func TestCopilotEventLoopResultWithoutSessionIDPreservesSessionStart(t *testing.T) {
t.Parallel()
// Defensive: if a result line arrives without a sessionId (older CLI,
// truncated output), the session.start id must not be wiped.
lines := []string{
fixtureSessionStart,
`{"type":"result","exitCode":0}`,
}
_, sessionID, _, _ := simulateCopilotEventLoop(t, lines)
if sessionID != "35059dc3-d928-4ffb-8616-b78938621d85" {
t.Fatalf("expected session.start id to be preserved when result has none, got %q", sessionID)
}
}
func TestCopilotEventLoopNonZeroExit(t *testing.T) {
t.Parallel()
lines := []string{fixtureResultNonZero}
_, sessionID, status, _ := simulateCopilotEventLoop(t, lines)
if status != "failed" {
t.Fatalf("expected failed, got %q", status)
}
if sessionID != "dead-beef" {
t.Fatalf("unexpected sessionId: %q", sessionID)
}
}
func TestCopilotEventLoopSessionError(t *testing.T) {
t.Parallel()
lines := []string{fixtureSessionError}
msgs, _, status, _ := simulateCopilotEventLoop(t, lines)
if status != "failed" {
t.Fatalf("expected failed, got %q", status)
}
var found bool
for _, m := range msgs {
if m.Type == MessageLog && m.Level == "error" && m.Content == "Rate limit exceeded" {
found = true
}
}
if !found {
t.Fatal("expected error log message")
}
}
func TestCopilotEventLoopSkipsUnknownTypes(t *testing.T) {
t.Parallel()
lines := []string{
fixtureEphemeral, // session.mcp_servers_loaded — should not produce messages
`{"type":"session.skills_loaded","data":{},"id":"x","timestamp":"2026-04-16T08:43:34.811Z","parentId":"y","ephemeral":true}`,
`{"type":"session.tools_updated","data":{"model":"claude-opus-4.6"},"id":"z","timestamp":"2026-04-16T08:43:36.397Z","parentId":"w","ephemeral":true}`,
}
msgs, _, _, _ := simulateCopilotEventLoop(t, lines)
if len(msgs) != 0 {
t.Fatalf("expected no messages for unknown/ephemeral event types, got %d: %+v", len(msgs), msgs)
}
}
func TestCopilotEventLoopMultiTurnUsage(t *testing.T) {
t.Parallel()
// Simulate: turn 0 has tool use (112 tokens), tool completes with model info,
// turn 1 has text response (106 tokens) — now under claude-opus-4.6 model.
lines := []string{
fixtureTurnStart,
fixtureAssistantMessageWithTools, // 112 outputTokens, activeModel="copilot"
fixtureToolExecComplete, // sets activeModel="claude-opus-4.6"
`{"type":"assistant.turn_start","data":{"turnId":"1"},"id":"t1","timestamp":"2026-04-16T08:44:01.000Z","parentId":"p1"}`,
// Turn 1 assistant.message with 106 tokens — should go under "claude-opus-4.6"
`{"type":"assistant.message","data":{"messageId":"msg-2","content":"Here are the files.","toolRequests":[],"interactionId":"int-1","outputTokens":106},"id":"m2","timestamp":"2026-04-16T08:44:02.000Z","parentId":"t1"}`,
fixtureResult,
}
_, _, _, usage := simulateCopilotEventLoop(t, lines)
if u := usage["copilot"]; u.OutputTokens != 112 {
t.Fatalf("expected 112 tokens under 'copilot', got %d", u.OutputTokens)
}
if u := usage["claude-opus-4.6"]; u.OutputTokens != 106 {
t.Fatalf("expected 106 tokens under 'claude-opus-4.6', got %d", u.OutputTokens)
}
}
func TestCopilotEventLoopSessionStartSetsModel(t *testing.T) {
t.Parallel()
lines := []string{
fixtureSessionStart,
fixtureTurnStart,
fixtureAssistantMessage, // 5 outputTokens
fixtureResult,
}
_, _, _, usage := simulateCopilotEventLoop(t, lines)
// session.start sets selectedModel to "claude-sonnet-4",
// so tokens should be attributed there, not "copilot".
if _, ok := usage["copilot"]; ok {
t.Fatal("expected no tokens under 'copilot' when session.start provides selectedModel")
}
u, ok := usage["claude-sonnet-4"]
if !ok {
t.Fatal("expected tokens under 'claude-sonnet-4'")
}
if u.OutputTokens != 5 {
t.Fatalf("expected 5 outputTokens, got %d", u.OutputTokens)
}
}
func TestCopilotEventLoopSeedModelFromOpts(t *testing.T) {
t.Parallel()
// No session.start — seed model comes from opts.Model (simulated via seedModel param).
lines := []string{
fixtureTurnStart,
fixtureAssistantMessage, // 5 outputTokens
fixtureResult,
}
_, _, _, usage := simulateCopilotEventLoopWithModel(t, lines, "gpt-4o")
u, ok := usage["gpt-4o"]
if !ok {
t.Fatal("expected tokens under 'gpt-4o'")
}
if u.OutputTokens != 5 {
t.Fatalf("expected 5 outputTokens, got %d", u.OutputTokens)
}
}
func TestCopilotEventLoopReasoning(t *testing.T) {
t.Parallel()
lines := []string{
fixtureReasoning,
fixtureReasoningDelta,
}
msgs, _, _, _ := simulateCopilotEventLoop(t, lines)
var thinking []string
for _, m := range msgs {
if m.Type == MessageThinking {
thinking = append(thinking, m.Content)
}
}
if len(thinking) != 2 {
t.Fatalf("expected 2 thinking messages, got %d: %v", len(thinking), thinking)
}
if thinking[0] != "Let me think about this..." {
t.Fatalf("unexpected reasoning content: %q", thinking[0])
}
if thinking[1] != "thinking step" {
t.Fatalf("unexpected reasoning_delta content: %q", thinking[1])
}
}
func TestCopilotEventLoopReasoningTextInMessage(t *testing.T) {
t.Parallel()
// assistant.message with reasoningText field set.
lines := []string{
`{"type":"assistant.message","data":{"messageId":"msg-r","content":"answer","toolRequests":[],"interactionId":"int-r","outputTokens":10,"reasoningText":"I thought carefully"},"id":"mr","timestamp":"2026-04-16T08:44:00.000Z","parentId":"p-1"}`,
}
msgs, _, _, _ := simulateCopilotEventLoop(t, lines)
var gotThinking bool
for _, m := range msgs {
if m.Type == MessageThinking && m.Content == "I thought carefully" {
gotThinking = true
}
}
if !gotThinking {
t.Fatal("expected MessageThinking from reasoningText in assistant.message")
}
}
func TestCopilotEventLoopSessionWarning(t *testing.T) {
t.Parallel()
lines := []string{fixtureSessionWarning}
msgs, _, status, _ := simulateCopilotEventLoop(t, lines)
// Warnings should NOT change finalStatus.
if status != "completed" {
t.Fatalf("expected completed, got %q", status)
}
var found bool
for _, m := range msgs {
if m.Type == MessageLog && m.Level == "warn" && m.Content == "You are approaching your rate limit" {
found = true
}
}
if !found {
t.Fatal("expected warn log message for session.warning")
}
}
func TestCopilotEventLoopDeltaFallbackOutput(t *testing.T) {
t.Parallel()
// Only deltas, no assistant.message — simulates process killed mid-stream.
lines := []string{
`{"type":"assistant.message_delta","data":{"messageId":"m1","deltaContent":"hello "},"id":"d1","timestamp":"2026-04-16T08:43:38.000Z","parentId":"p1","ephemeral":true}`,
`{"type":"assistant.message_delta","data":{"messageId":"m1","deltaContent":"world"},"id":"d2","timestamp":"2026-04-16T08:43:38.100Z","parentId":"p1","ephemeral":true}`,
}
st := newCopilotEventState("copilot")
for _, line := range lines {
var evt copilotEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
t.Fatal(err)
}
handleCopilotEvent(evt, st)
}
if st.output.String() != "hello world" {
t.Fatalf("expected output 'hello world', got %q", st.output.String())
}
}
// ── Arg builder tests ──
func TestBuildCopilotArgsBaseline(t *testing.T) {
t.Parallel()
args := buildCopilotArgs("write a haiku", ExecOptions{}, slog.Default())
expected := []string{
"-p", "write a haiku",
"--output-format", "json",
"--allow-all",
"--no-ask-user",
}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
}
for i, want := range expected {
if args[i] != want {
t.Fatalf("expected args[%d] = %q, got %q", i, want, args[i])
}
}
}
func TestBuildCopilotArgsWithModel(t *testing.T) {
t.Parallel()
args := buildCopilotArgs("hi", ExecOptions{Model: "gpt-4o"}, slog.Default())
var foundModel bool
for i, a := range args {
if a == "--model" {
if i+1 >= len(args) || args[i+1] != "gpt-4o" {
t.Fatalf("expected --model followed by gpt-4o, got %v", args)
}
foundModel = true
break
}
}
if !foundModel {
t.Fatalf("expected --model flag when Model is set, got args=%v", args)
}
}
func TestBuildCopilotArgsWithResume(t *testing.T) {
t.Parallel()
args := buildCopilotArgs("hi", ExecOptions{ResumeSessionID: "sess-42"}, slog.Default())
var foundResume bool
for i, a := range args {
if a == "--resume" {
if i+1 >= len(args) || args[i+1] != "sess-42" {
t.Fatalf("expected --resume followed by session id, got %v", args)
}
foundResume = true
break
}
}
if !foundResume {
t.Fatalf("expected --resume flag when ResumeSessionID is set, got args=%v", args)
}
}
func TestBuildCopilotArgsOmitsOptionalWhenEmpty(t *testing.T) {
t.Parallel()
args := buildCopilotArgs("hi", ExecOptions{}, slog.Default())
for _, a := range args {
if a == "--model" {
t.Fatalf("expected no --model flag when Model is empty, got args=%v", args)
}
if a == "--resume" {
t.Fatalf("expected no --resume flag when ResumeSessionID is empty, got args=%v", args)
}
}
}
func TestBuildCopilotArgsPassesThroughCustomArgs(t *testing.T) {
t.Parallel()
args := buildCopilotArgs("hi", ExecOptions{
CustomArgs: []string{"--max-turns", "50"},
}, slog.Default())
if args[len(args)-2] != "--max-turns" || args[len(args)-1] != "50" {
t.Fatalf("expected --max-turns 50 at end of args, got %v", args)
}
}
func TestBuildCopilotArgsFiltersBlockedCustomArgs(t *testing.T) {
t.Parallel()
args := buildCopilotArgs("hi", ExecOptions{
CustomArgs: []string{"--output-format", "text", "--max-turns", "50"},
}, slog.Default())
for i, a := range args {
if a == "--output-format" && i+1 < len(args) && args[i+1] == "text" {
t.Fatalf("blocked --output-format text should have been filtered: %v", args)
}
}
found := false
for i, a := range args {
if a == "--max-turns" && i+1 < len(args) && args[i+1] == "50" {
found = true
}
}
if !found {
t.Fatalf("expected --max-turns 50 to pass through, got %v", args)
}
}
func TestBuildCopilotArgsBlocksResumeAndACP(t *testing.T) {
t.Parallel()
args := buildCopilotArgs("hi", ExecOptions{
CustomArgs: []string{"--resume", "bad-session", "--acp", "--yolo"},
}, slog.Default())
for _, a := range args {
if a == "bad-session" {
t.Fatalf("blocked --resume value should have been filtered: %v", args)
}
if a == "--acp" {
t.Fatalf("blocked --acp should have been filtered: %v", args)
}
if a == "--yolo" {
t.Fatalf("blocked --yolo should have been filtered: %v", args)
}
}
}