Skip to content
Merged
209 changes: 209 additions & 0 deletions cmd/entire/cli/agent/copilotcli/compat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package copilotcli

import (
"encoding/json"
"errors"
"fmt"
"slices"
"time"
)

// HookHost identifies which host format produced a copilot-compatible hook payload.
type HookHost string

const (
HostUnknown HookHost = "unknown"
HostCopilotCLI HookHost = "copilot-cli"
HostVSCode HookHost = "vscode"
)

// VS Code hookEventName values (from official VS Code docs).
// See: https://code.visualstudio.com/docs/copilot/customization/hooks
const (
VSCodeEventSessionStart = "SessionStart"
VSCodeEventUserPromptSubmit = "UserPromptSubmit"
VSCodeEventStop = "Stop"
VSCodeEventPreToolUse = "PreToolUse"
VSCodeEventPostToolUse = "PostToolUse"
VSCodeEventPreCompact = "PreCompact"
VSCodeEventSubagentStart = "SubagentStart"
VSCodeEventSubagentStop = "SubagentStop"
)

// vsCodeEventToHookNames maps each VS Code hookEventName to the CLI hook name(s)
// that are allowed to carry that event. "Stop" maps to both agent-stop and
// session-end because VS Code uses a single Stop event where Copilot CLI
// distinguishes the two.
var vsCodeEventToHookNames = map[string][]string{
VSCodeEventUserPromptSubmit: {HookNameUserPromptSubmitted},
VSCodeEventSessionStart: {HookNameSessionStart},
VSCodeEventStop: {HookNameAgentStop, HookNameSessionEnd},
VSCodeEventSubagentStop: {HookNameSubagentStop},
VSCodeEventPreToolUse: {HookNamePreToolUse},
VSCodeEventPostToolUse: {HookNamePostToolUse},
VSCodeEventPreCompact: {},
VSCodeEventSubagentStart: {},
}

type hookEnvelope struct {
Host HookHost
SessionID string
Prompt string
TranscriptPath string
HookEventName string
Source string
InitialPrompt string
StopReason string
Reason string
Timestamp time.Time
}

func parseHookEnvelope(data []byte) (*hookEnvelope, error) {
if len(data) == 0 {
return nil, errors.New("empty hook input")
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse hook input: %w", err)
}

env := &hookEnvelope{
Host: detectHookHost(raw),
SessionID: firstString(raw, "sessionId", "session_id"),
Prompt: firstString(raw, "prompt"),
TranscriptPath: firstString(raw, "transcriptPath", "transcript_path"),
HookEventName: firstString(raw, "hookEventName"),
Source: firstString(raw, "source"),
InitialPrompt: firstString(raw, "initialPrompt"),
StopReason: firstString(raw, "stopReason"),
Reason: firstString(raw, "reason"),
}

ts, err := parseTimestamp(raw["timestamp"])
if err != nil {
return nil, fmt.Errorf("failed to parse hook input: %w", err)
}
env.Timestamp = ts

if env.Timestamp.IsZero() {
env.Timestamp = time.Now()
}

return env, nil
}

func detectHookHost(raw map[string]json.RawMessage) HookHost {
if isJSONString(raw["hookEventName"]) {
return HostVSCode
}
if isJSONString(raw["transcript_path"]) {
return HostVSCode
}
if isJSONString(raw["timestamp"]) {
return HostVSCode
}
if _, ok := raw["transcriptPath"]; ok {
return HostCopilotCLI
}
if isJSONNumber(raw["timestamp"]) {
return HostCopilotCLI
}
return HostUnknown
}

func firstString(raw map[string]json.RawMessage, keys ...string) string {
for _, key := range keys {
value, ok := raw[key]
if !ok {
continue
}
var s string
if err := json.Unmarshal(value, &s); err == nil {
return s
}
}
return ""
}

func parseTimestamp(raw json.RawMessage) (time.Time, error) {
if len(raw) == 0 || string(raw) == "null" {
return time.Time{}, nil
}

var millis int64
if err := json.Unmarshal(raw, &millis); err == nil {
if millis == 0 {
return time.Time{}, nil // Treat epoch as missing — triggers time.Now() fallback.
}
return time.UnixMilli(millis), nil
}

var ts string
if err := json.Unmarshal(raw, &ts); err != nil {
return time.Time{}, fmt.Errorf("unmarshal timestamp string: %w", err)
}

parsed, err := time.Parse(time.RFC3339Nano, ts)
if err != nil {
return time.Time{}, fmt.Errorf("parse timestamp %q: %w", ts, err)
}
return parsed, nil
}
Comment thread
peyton-alt marked this conversation as resolved.

func isJSONString(raw json.RawMessage) bool {
if len(raw) == 0 || raw[0] != '"' {
return false
}
var s string
return json.Unmarshal(raw, &s) == nil
}

func isJSONNumber(raw json.RawMessage) bool {
if len(raw) == 0 || raw[0] == 'n' {
return false
}
var n int64
return json.Unmarshal(raw, &n) == nil
}

// validateVSCodeEvent checks whether the hookEventName is consistent with the
// CLI hook subcommand that was invoked. Returns true if the event should be
// processed, false if it should be silently skipped (mismatch or unknown event).
func validateVSCodeEvent(env *hookEnvelope, hookName string) bool {
hookEventName := env.HookEventName
allowedHooks, known := vsCodeEventToHookNames[hookEventName]
if !known {
return false
}
if !slices.Contains(allowedHooks, hookName) {
return false
}

// VS Code overloads "Stop" for both end-of-turn and terminal session-stop
// payloads. Route them by reason to avoid ending sessions on ordinary turns.
if hookEventName == VSCodeEventStop {
isTerminal := isTerminalVSCodeStop(env)
switch hookName {
case HookNameAgentStop:
return !isTerminal
case HookNameSessionEnd:
return isTerminal
}
}

return true
}

func isTerminalVSCodeStop(env *hookEnvelope) bool {
if env.Reason != "" {
return true
}

switch env.StopReason {
case "", "end_turn":
return false
default:
return true
}
}
161 changes: 161 additions & 0 deletions cmd/entire/cli/agent/copilotcli/compat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package copilotcli

import (
"encoding/json"
"testing"
)

func TestDetectHookHost(t *testing.T) {
t.Parallel()

tests := []struct {
name string
raw string
want HookHost
}{
{
name: "copilot cli numeric timestamp",
raw: `{"timestamp":1771480081360,"sessionId":"sess-123","prompt":"hi"}`,
want: HostCopilotCLI,
},
{
name: "vscode hook event field",
raw: `{"timestamp":"2026-02-09T10:30:00.000Z","sessionId":"sess-123","hookEventName":"UserPromptSubmit","prompt":"hi"}`,
want: HostVSCode,
},
{
name: "vscode transcript_path",
raw: `{"timestamp":1771480081360,"sessionId":"sess-123","transcript_path":"/tmp/transcript.json"}`,
want: HostVSCode,
},
{
name: "null hookEventName is not vscode",
raw: `{"timestamp":1771480081360,"sessionId":"sess-123","hookEventName":null}`,
want: HostCopilotCLI,
},
{
name: "null transcript_path is not vscode",
raw: `{"timestamp":1771480081360,"sessionId":"sess-123","transcript_path":null}`,
want: HostCopilotCLI,
},
{
name: "unknown payload",
raw: `{"sessionId":"sess-123"}`,
want: HostUnknown,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(tt.raw), &raw); err != nil {
t.Fatalf("unmarshal test fixture: %v", err)
}

if got := detectHookHost(raw); got != tt.want {
t.Fatalf("detectHookHost() = %q, want %q", got, tt.want)
}
})
}
}

func TestParseTimestamp_NullAndZeroFallBackToNow(t *testing.T) {
t.Parallel()

tests := []struct {
name string
raw string
}{
{name: "null timestamp", raw: `{"timestamp":null,"sessionId":"s"}`},
{name: "zero timestamp", raw: `{"timestamp":0,"sessionId":"s"}`},
{name: "missing timestamp", raw: `{"sessionId":"s"}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

env, err := parseHookEnvelope([]byte(tt.raw))
if err != nil {
t.Fatalf("parseHookEnvelope() error = %v", err)
}
if env.Timestamp.IsZero() {
t.Fatal("expected time.Now() fallback, got zero time")
}
if env.Timestamp.Year() < 2025 {
t.Fatalf("expected recent timestamp from time.Now(), got %v", env.Timestamp)
}
})
}
}

func TestDetectHookHost_NullTimestampIsNotCopilotCLI(t *testing.T) {
t.Parallel()

var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(`{"timestamp":null,"sessionId":"s"}`), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}

if got := detectHookHost(raw); got == HostCopilotCLI {
t.Fatalf("null timestamp should not classify as HostCopilotCLI, got %q", got)
}
}

func TestParseHookEnvelope_AcceptsAlternateTranscriptPathAndTimestampFormats(t *testing.T) {
t.Parallel()

tests := []struct {
name string
raw string
host HookHost
path string
}{
{
name: "copilot cli fields",
raw: `{"timestamp":1771480085412,"sessionId":"sess-123","transcriptPath":"/tmp/copilot.jsonl"}`,
host: HostCopilotCLI,
path: "/tmp/copilot.jsonl",
},
{
name: "vscode fields",
raw: `{"timestamp":"2026-02-09T10:30:00.000Z","sessionId":"sess-123","hookEventName":"Stop","transcript_path":"/tmp/vscode.json"}`,
host: HostVSCode,
path: "/tmp/vscode.json",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

env, err := parseHookEnvelope([]byte(tt.raw))
if err != nil {
t.Fatalf("parseHookEnvelope() error = %v", err)
}
if env.Host != tt.host {
t.Fatalf("Host = %q, want %q", env.Host, tt.host)
}
if env.TranscriptPath != tt.path {
t.Fatalf("TranscriptPath = %q, want %q", env.TranscriptPath, tt.path)
}
if env.Timestamp.IsZero() {
t.Fatal("Timestamp should be populated")
}
})
}
}

func TestParseHookEnvelope_AcceptsSnakeCaseSessionID(t *testing.T) {
t.Parallel()

env, err := parseHookEnvelope([]byte(`{"timestamp":"2026-02-09T10:30:00.000Z","session_id":"sess-456","hookEventName":"UserPromptSubmit","prompt":"hi"}`))
if err != nil {
t.Fatalf("parseHookEnvelope() error = %v", err)
}
if env.SessionID != "sess-456" {
t.Fatalf("SessionID = %q, want %q", env.SessionID, "sess-456")
}
}
Loading
Loading