Skip to content

Commit 2bf4d8d

Browse files
committed
fix(mobile): follow the conversation when it moves, and never eat a question
Two things a question could fall through. A question typed before `enterChat` finished found no chat, and `say` returned the identifier it was given as though it had asked. The composer had already emptied, so the question was gone with nothing on screen to say so. It now waits for the open, and a chat that is genuinely not there raises rather than reporting success — which puts the question back on screen with a Retry under it, the same as one whose answer never arrived. A queued question asked on another model clones the conversation, and `drain` ignored the new identifier: the transcript moved to a session the screen was not watching. The chat that was left now points at the one it became, and `useChat` follows that, so `say` and `retryChat` hand nothing back. The screen is not always the mover, which is why the return value was the wrong seam.
1 parent a474a70 commit 2bf4d8d

4 files changed

Lines changed: 101 additions & 34 deletions

File tree

‎apps/mobile/AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ Add narrow temporary logs at the real boundaries. Reproduce. Read the tmux servi
9393

9494
The Chat tab runs `@kilocode/harness-sdk` on the device. `src/lib/chat/` holds the registry (the conversations that are running), the layers (the plugins they run on) and the store (the list and its CRUD). The SDK saves one conversation; this app owns the list, the ordering, the titles, the deleting and the scoping.
9595

96+
**A conversation can move, and the screen follows the state rather than a return value.** Switching models clones the conversation onto a new session, and the mover is not always the screen: a question typed on another model while an answer was arriving moves the chat from inside the registry. So the chat that was left points at the one it became, and `useChat` follows that.
97+
9698
A chat is opened with the SDK's `time` tool and no other. A model has no clock, so it answers a dated question from its training date, confidently and wrong. The other three tools the SDK ships stay off: the composer is already how a person is asked something, a subagent costs a second session, and a to-do list is working memory for a run a chat does not have.
9799

98100
**Prove a chat change against the same eleven models the SDK's live runs use.** The list is in `packages/harness-sdk/e2e/setup.ts`, and `pnpm --filter @kilocode/harness-sdk test:e2e:time full` is the sweep across all of them. Take one of those models through the app itself on the simulator as well: the sweep proves what the models do, the simulator proves this app's wiring, and neither proves the other. Both cost real money, so run the sweep when the tool set or the system prompt changes, not for a change to a screen.

‎apps/mobile/src/lib/chat/registry.test.ts‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ vi.mock('./store', () => ({
7474
touchChat: () => undefined,
7575
}));
7676

77-
const { releaseChat, say, startChat, stopChat } = await import('./registry');
77+
const { enterChat, releaseChat, say, startChat, stopChat } = await import('./registry');
7878
const { snapshotOf } = await import('./state');
7979

8080
const place = { chatScope: 'me:personal', org: { kind: 'personal' } } as const;
@@ -156,3 +156,50 @@ describe('a second question while the first is being answered', () => {
156156
expect(asked.map(one => one.text)).toEqual(['first']);
157157
});
158158
});
159+
160+
describe('a chat that moved', () => {
161+
it('leaves the chat it moved off pointing at the one it became', async () => {
162+
await say(opened, 'first', 'kilo/one');
163+
await settled();
164+
await say(opened, 'second', 'kilo/two');
165+
await settled();
166+
167+
finish?.();
168+
await settled();
169+
170+
/* The queued question moved the conversation, and nobody handed the new
171+
identifier back to the screen. The chat it left says where it went, so a
172+
screen watching the old one follows without being told. */
173+
expect(snapshotOf(opened).sessionId).toBe('s2');
174+
});
175+
});
176+
177+
describe('a question asked before the session has opened', () => {
178+
it('waits for the open rather than vanishing', async () => {
179+
await releaseChat(opened);
180+
asked.length = 0;
181+
182+
/* No await: this is a person typing while the screen is still opening the
183+
chat, which is exactly the window the question used to be dropped in. */
184+
const entering = enterChat(place, 'later');
185+
await say('later', 'typed early', 'kilo/one');
186+
await entering;
187+
await settled();
188+
189+
expect(asked).toEqual([{ sessionId: 'later', text: 'typed early' }]);
190+
});
191+
192+
it('says so rather than reporting success when the chat is not there', async () => {
193+
await releaseChat(opened);
194+
asked.length = 0;
195+
196+
await say(opened, 'into nothing', 'kilo/one');
197+
await settled();
198+
199+
expect(asked).toEqual([]);
200+
/* The question is on screen with a Retry under it, which is what every
201+
other question that never reached the model gets. */
202+
expect(snapshotOf(opened)).toMatchObject({ status: 'idle', asked: 'into nothing' });
203+
expect(snapshotOf(opened).failed).not.toBeNull();
204+
});
205+
});

‎apps/mobile/src/lib/chat/registry.ts‎

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -192,37 +192,43 @@ async function reopen(place: ChatPlace, sessionId: string): Promise<void> {
192192
*
193193
* A model that is not the one the session was opened on moves the conversation
194194
* first, because a session freezes its model. The identifier changes when it
195-
* does, which is why this answers with the one to carry on with.
195+
* does, and the state of the chat it moved off says where it went, so whoever
196+
* is watching follows without being told.
196197
*/
197-
export async function say(sessionId: string, text: string, model: string): Promise<string> {
198-
const held = chats.get(sessionId);
199-
if (held?.answering !== undefined) {
200-
/* A session answers one question at a time, and the composer stays open
201-
while it works. So a second question joins the line rather than racing
202-
the first, and it is on screen while it waits. It is held in memory
203-
only: an answer that is still arriving is not written down either. */
204-
held.waiting.push({ text, model });
205-
change(sessionId, { waiting: held.waiting.map(one => one.text) });
206-
return sessionId;
207-
}
198+
export async function say(sessionId: string, text: string, model: string): Promise<void> {
199+
/* Where the failure below belongs. It is the chat the move landed on, not
200+
the one it started from, or the report goes to a chat nobody is watching. */
201+
let current = sessionId;
208202
try {
209-
const moved = await ontoModel(sessionId, model);
210-
const chat = chats.get(moved);
203+
/* A person can type before the session has finished opening, and a
204+
question asked of a chat that is not there yet used to vanish with the
205+
composer reporting success. It waits for the open instead. */
206+
await opening.get(sessionId);
207+
const held = chats.get(sessionId);
208+
if (held?.answering !== undefined) {
209+
/* A session answers one question at a time, and the composer stays open
210+
while it works. So a second question joins the line rather than racing
211+
the first, and it is on screen while it waits. It is held in memory
212+
only: an answer that is still arriving is not written down either. */
213+
held.waiting.push({ text, model });
214+
change(sessionId, { waiting: held.waiting.map(one => one.text) });
215+
return;
216+
}
217+
current = await ontoModel(sessionId, model);
218+
const chat = chats.get(current);
211219
if (chat === undefined) {
212-
return moved;
220+
throw new Error('the chat is not open');
213221
}
214222
const runtime = await runtimeFor(chat);
215-
await rememberAsked(moved, text);
216-
touchChat(await open(), moved, Date.now());
217-
change(moved, { status: 'working', answering: '', asked: text, failed: null });
218-
chat.answering = runtime.runFork(reading(moved, text, runtime));
219-
return moved;
223+
await rememberAsked(current, text);
224+
touchChat(await open(), current, Date.now());
225+
change(current, { status: 'working', answering: '', asked: text, failed: null });
226+
chat.answering = runtime.runFork(reading(current, text, runtime));
220227
} catch (error) {
221-
/* The move, or the write that remembers the question, failed. The question
222-
is not lost: it stays on screen with a Retry under it, the same as one
223-
whose answer never arrived. */
224-
change(sessionId, { status: 'idle', answering: '', asked: text, failed: reason(error) });
225-
return sessionId;
228+
/* The open, the move, or the write that remembers the question failed. The
229+
question is not lost: it stays on screen with a Retry under it, the same
230+
as one whose answer never arrived. */
231+
change(current, { status: 'idle', answering: '', asked: text, failed: reason(error) });
226232
}
227233
}
228234

@@ -231,13 +237,11 @@ const reason = (error: unknown): string =>
231237
error instanceof Error ? error.message : 'the question could not be sent';
232238

233239
/** Asks again what was asked and never answered. */
234-
export async function retryChat(sessionId: string): Promise<string> {
240+
export async function retryChat(sessionId: string): Promise<void> {
235241
const { asked, model } = snapshotOf(sessionId);
236-
if (asked === null) {
237-
return sessionId;
242+
if (asked !== null) {
243+
await say(sessionId, asked, model);
238244
}
239-
const moved = await say(sessionId, asked, model);
240-
return moved;
241245
}
242246

243247
/** Reads one answer to the end, however it ends. */
@@ -363,9 +367,13 @@ async function ontoModel(sessionId: string, model: string): Promise<string> {
363367
await moveAsked(sessionId, handle.id);
364368
const turns = await runtime.runPromise(handle.history);
365369
chats.delete(sessionId);
366-
forgetState(sessionId);
367370
chats.set(handle.id, { ...chat, handle, scope, answering: undefined });
368371
change(handle.id, { ...held, sessionId: handle.id, model, turns });
372+
/* The chat it moved off is left pointing at the one it became, rather than
373+
forgotten. Whoever asked for the move is not always the screen — a question
374+
queued on another model moves the chat from inside the registry — so the
375+
state is what says where the conversation went. */
376+
change(sessionId, { sessionId: handle.id });
369377
await runtime.runPromise(Scope.close(chat.scope, Exit.void));
370378
forgetSession(database, sessionId);
371379
return handle.id;

‎apps/mobile/src/lib/chat/use-chat.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,26 @@ export function useChat(place: ChatPlace | null, opened: string): OpenChat {
125125
useCallback(() => snapshotOf(sessionId), [sessionId])
126126
);
127127

128+
// Switching models clones the conversation onto a new session, and the screen
129+
// has to follow it. It is not always this screen that asks: a question typed
130+
// on another model while an answer was arriving moves the chat when it is
131+
// finally asked. The state says where it went, so the screen reads that
132+
// rather than every mover having to hand the identifier back.
133+
const moved = state.sessionId;
134+
useEffect(() => {
135+
setSessionId(moved);
136+
}, [moved]);
137+
128138
return {
129139
state,
130140
send: async (text, model) => {
131-
setSessionId(await say(sessionId, text, model));
141+
await say(sessionId, text, model);
132142
},
133143
stop: async () => {
134144
await stopChat(sessionId);
135145
},
136146
retry: async () => {
137-
setSessionId(await retryChat(sessionId));
147+
await retryChat(sessionId);
138148
},
139149
};
140150
}

0 commit comments

Comments
 (0)