Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions internal/context/source_todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ func (todosSource) Sections(ctx context.Context, input BuildInput) ([]promptSect
return nil, err
}
if len(input.Todos) == 0 {
return nil, nil
return []promptSection{
{
Title: "Todo State",
Content: "None",
Comment thread
Yumiue marked this conversation as resolved.
},
}, nil
}

active := make([]agentsession.TodoItem, 0, len(input.Todos))
Expand All @@ -37,7 +42,12 @@ func (todosSource) Sections(ctx context.Context, input BuildInput) ([]promptSect
}
}
if len(active) == 0 {
return nil, nil
return []promptSection{
{
Title: "Todo State",
Content: "None",
},
}, nil
}

sort.SliceStable(active, func(i, j int) bool {
Expand Down
8 changes: 4 additions & 4 deletions internal/context/source_todos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ func TestTodosSourceSectionsBoundaries(t *testing.T) {
if err != nil {
t.Fatalf("Sections() error = %v", err)
}
if sections != nil {
t.Fatalf("Sections() = %+v, want nil", sections)
if len(sections) != 1 || sections[0].Content != "None" {
t.Fatalf("Sections() = %+v, want single section with 'None'", sections)
}

ctx, cancel := stdcontext.WithCancel(stdcontext.Background())
Expand All @@ -100,8 +100,8 @@ func TestTodosSourceSectionsAllTerminal(t *testing.T) {
if err != nil {
t.Fatalf("Sections() error = %v", err)
}
if sections != nil {
t.Fatalf("Sections() = %+v, want nil for all terminal todos", sections)
if len(sections) != 1 || sections[0].Content != "None" {
t.Fatalf("Sections() = %+v, want single section with 'None' for all terminal todos", sections)
}
}

Expand Down
3 changes: 3 additions & 0 deletions internal/promptasset/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ func TestPlanModePromptTemplates(t *testing.T) {
if !strings.Contains(PlanModePrompt("build_execute"), "create current-run required todos") {
t.Fatalf("expected build prompt to require direct-build todo bootstrap")
}
if !strings.Contains(PlanModePrompt("build_execute"), "Todo State is attached as `None`") {
t.Fatalf("expected build prompt to bootstrap when Todo State is None")
}
if !strings.Contains(PlanModePrompt("build_execute"), "simple conversational inputs") {
t.Fatalf("expected build prompt to cover simple conversational completion")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ You are currently in build execution.
- If a current plan summary is attached, use it as guidance by default.
- If the summary is insufficient for the current task, consult the attached full plan view when available.
- If no current plan is attached, continue using task state, todos, and the conversation context.
- If no Todo State is attached, create current-run required todos with `todo_write` before the first substantive tool call for project analysis, documentation writing, code changes, multi-step debugging, or verification work.
- If no Todo State is attached, or Todo State is attached as `None`, create current-run required todos with `todo_write` before the first substantive tool call for project analysis, documentation writing, code changes, multi-step debugging, or verification work.
- Do not update or complete todo IDs that are not present in the current Todo State; create new current-run todos instead.
- Small necessary deviations are allowed, but explain why they are needed.
- Do not create or rewrite the current full plan in this stage.
Expand Down
1 change: 1 addition & 0 deletions internal/runtime/controlplane/phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ var allowedRunStateTransitions = map[RunState]map[RunState]struct{}{
RunStateVerify: {
RunStateVerify: {},
RunStatePlan: {},
RunStateExecute: {},
RunStateCompacting: {},
RunStateWaitingUserQuestion: {},
RunStateWaitingPermission: {},
Expand Down
1 change: 1 addition & 0 deletions internal/runtime/controlplane/phase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestValidateRunStateTransitionMainlineAndGovernanceStates(t *testing.T) {
{from: RunStatePlan, to: RunStateExecute},
{from: RunStateExecute, to: RunStateVerify},
{from: RunStateVerify, to: RunStatePlan},
{from: RunStateVerify, to: RunStateExecute},
{from: RunStatePlan, to: RunStateCompacting},
{from: RunStateCompacting, to: RunStatePlan},
{from: RunStateExecute, to: RunStateWaitingPermission},
Expand Down
15 changes: 15 additions & 0 deletions web/src/stores/useRuntimeInsightStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,21 @@ describe('useRuntimeInsightStore', () => {
expect(state.todoHistory.a).toBeDefined()
})

it('applyTodoSnapshot can clear stale conflict on reset while preserving history', () => {
const store = useRuntimeInsightStore.getState()
store.setTodoSnapshot({
items: [{ id: 'a', content: 'task a', status: 'in_progress', required: true, revision: 1 }],
})
store.setTodoConflict({ action: 'todo_conflict', reason: 'todo_not_found' })

store.applyTodoSnapshot({ items: [] }, { clearConflict: true })

const state = useRuntimeInsightStore.getState()
expect(state.todoSnapshot?.items).toEqual([])
expect(state.todoConflict).toBeNull()
expect(state.todoHistory.a).toBeDefined()
})

it('setTodoSnapshot accumulates todoHistory across replacements', () => {
const store = useRuntimeInsightStore.getState()
store.setTodoSnapshot({
Expand Down
12 changes: 7 additions & 5 deletions web/src/stores/useRuntimeInsightStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ interface RuntimeInsightState {
failVerification: (payload: VerificationFailedPayload) => void
setAcceptanceDecision: (payload: AcceptanceDecidedPayload | null) => void
setTodoSnapshot: (snapshot: TodoSnapshot | null) => void
applyTodoSnapshot: (snapshot: TodoSnapshot | null) => void
applyTodoSnapshot: (snapshot: TodoSnapshot | null, options?: { clearConflict?: boolean }) => void
addTodoEvent: (event: TodoEventPayload) => void
setTodoConflict: (event: TodoEventPayload | null) => void
setBudgetChecked: (payload: BudgetCheckedPayload) => void
Expand Down Expand Up @@ -110,13 +110,13 @@ export const useRuntimeInsightStore = create<RuntimeInsightState>((set) => ({
}
return { todoSnapshot, todoConflict: null, todoHistory }
}),
applyTodoSnapshot: (todoSnapshot) => set((s) => {
applyTodoSnapshot: (todoSnapshot, options) => set((s) => {
const items = todoSnapshot?.items ?? []
if (!todoSnapshot) {
return { todoSnapshot: null }
return options?.clearConflict ? { todoSnapshot: null, todoConflict: null } : { todoSnapshot: null }
}
if (items.length === 0) {
return { todoSnapshot }
return options?.clearConflict ? { todoSnapshot, todoConflict: null } : { todoSnapshot }
}
const now = Date.now()
const todoHistory = { ...s.todoHistory }
Expand All @@ -128,7 +128,9 @@ export const useRuntimeInsightStore = create<RuntimeInsightState>((set) => ({
firstSeenAt: prev?.firstSeenAt ?? now,
}
}
return { todoSnapshot, todoHistory }
return options?.clearConflict
? { todoSnapshot, todoConflict: null, todoHistory }
: { todoSnapshot, todoHistory }
}),
addTodoEvent: (event) => set((s) => ({ todoEvents: [...s.todoEvents, event] })),
setTodoConflict: (todoConflict) => set({ todoConflict }),
Expand Down
50 changes: 50 additions & 0 deletions web/src/utils/eventBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,56 @@ describe("eventBridge", () => {
);
});

it("TodoSnapshotUpdated reset clears stale TodoConflict", () => {
const api = createMockGatewayAPI();
handleGatewayEvent(
{
type: EventType.TodoConflict,
payload: {
payload: {
runtime_event_type: EventType.TodoConflict,
payload: { action: "update", reason: "todo_not_found" },
},
},
session_id: "sess-1",
run_id: "run-1",
},
api,
);
expect(useRuntimeInsightStore.getState().todoConflict?.reason).toBe(
"todo_not_found",
);

handleGatewayEvent(
{
type: EventType.TodoSnapshotUpdated,
payload: {
payload: {
runtime_event_type: EventType.TodoSnapshotUpdated,
payload: {
action: "reset",
reason: "new_user_run",
items: [],
summary: {
total: 0,
required_total: 0,
required_completed: 0,
required_failed: 0,
required_open: 0,
},
},
},
},
session_id: "sess-1",
run_id: "run-2",
},
api,
);

expect(useRuntimeInsightStore.getState().todoConflict).toBeNull();
expect(useRuntimeInsightStore.getState().todoSnapshot?.items).toEqual([]);
});

it("TodoUpdated clears TodoConflict", () => {
const api = createMockGatewayAPI();
// Set conflict first
Expand Down
5 changes: 4 additions & 1 deletion web/src/utils/eventBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,10 +1026,13 @@ export function handleGatewayEvent(
if (payload) {
insightStore.addTodoEvent(payload);
if (payload.items) {
const clearConflict =
payload.action === "reset" ||
(payload.items.length === 0 && payload.summary?.total === 0);
insightStore.applyTodoSnapshot({
items: payload.items,
summary: payload.summary,
});
}, { clearConflict });
}
}
break;
Expand Down
Loading