capture a forked session's real id from its init event
A --fork-session branch is spawned without --session-id, so claude mints a new session id that only arrives in the init event; the ManagedSession was left holding its placeholder. StreamJsonSession now captures session_id from the first event that carries it and exposes it via claudeSessionId / sessionIdResolved; the orchestrator folds that back into ManagedSession.sessionId (idempotent for normal sessions). A fork can now itself be resumed or forked. T-185. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2298,3 +2298,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 08:49:08', '2026-05-31 08:49:08', '2026-05-31 08:49:08', NULL, 'e7e9fd59ba8adf45431b759b693d4022', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'status', 'in_progress', 'done', NULL, '2026-05-31 09:30:11', '2026-05-31 09:30:11', '2026-05-31 09:30:11', NULL, 'd9c300f2f35571747572037bc8cb6983', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-184', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 09:49:55', '2026-05-31 09:49:55', '2026-05-31 09:49:55', NULL, 'c307a16d7851ec46ee57ff8422380ff7', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-184', 'status', 'in_progress', 'done', NULL, '2026-05-31 10:17:09', '2026-05-31 10:17:09', '2026-05-31 10:17:09', NULL, '8d23aadcaeebbf9b08ba0ccf495578bf', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2876,3 +2876,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'in_progress', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-31 08:49:08', NULL, '4c86cfc6e81700b9b94df5420705d849', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'done', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-31 09:30:11', NULL, '9589b5d69a790d02598466697638dde6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-184', 'task', 'T-164', 'Validate partial-message wire shape from --include-partial-messages against a live capture', 'T-168 follow-up. T-168 wired --include-partial-messages into the interactive stream-json session (stream_json_session.dart) and added a partial-streaming handler that ASSUMES claude emits incremental assistant events carrying partial:true sharing one message.id. That shape is UNVERIFIED: the 2.1.150 spike (docs/spikes/cc-stream-json-control-protocol-2.1.150.md) only documents non-partial assistant events (one per content block, no partial flag), and the flag''s --help says it ''only works with --print'' while clide runs the interactive control protocol (--input-format/--output-format stream-json, not --print). The handler degrades gracefully (unmatched shapes fall through to parseTranscriptChunk and are ignored — token streaming is inert but nothing breaks), and the T-168 fixtures are synthetic so they pass regardless of reality. Do: capture a real claude turn over the interactive stream-json transport with --include-partial-messages and record the actual partial event envelope (likely a stream_event content_block_delta, not assistant+partial:true). Then either (a) fix the handler in stream_json_session.dart::_onLine to match the real shape and add a fixture from the live capture, or (b) if the flag is a true no-op in the interactive (non-print) mode, drop --include-partial-messages from the argv and the dead partial-handler branch, and pin the finding in the spike doc. Either way, update the UNVERIFIED-WIRE-SHAPE comment in stream_json_session.dart and the spike. Acceptance: the partial path is validated against a live capture, the handler matches the real shape (or is removed), a fixture derived from the capture covers it, and the spike + code comment reflect the confirmed shape.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-30 11:42:53', '2026-05-31 09:49:55', NULL, 'ef7ba71512bcda769d81f79858afab05', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-184', 'task', 'T-164', 'Validate partial-message wire shape from --include-partial-messages against a live capture', 'T-168 follow-up. T-168 wired --include-partial-messages into the interactive stream-json session (stream_json_session.dart) and added a partial-streaming handler that ASSUMES claude emits incremental assistant events carrying partial:true sharing one message.id. That shape is UNVERIFIED: the 2.1.150 spike (docs/spikes/cc-stream-json-control-protocol-2.1.150.md) only documents non-partial assistant events (one per content block, no partial flag), and the flag''s --help says it ''only works with --print'' while clide runs the interactive control protocol (--input-format/--output-format stream-json, not --print). The handler degrades gracefully (unmatched shapes fall through to parseTranscriptChunk and are ignored — token streaming is inert but nothing breaks), and the T-168 fixtures are synthetic so they pass regardless of reality. Do: capture a real claude turn over the interactive stream-json transport with --include-partial-messages and record the actual partial event envelope (likely a stream_event content_block_delta, not assistant+partial:true). Then either (a) fix the handler in stream_json_session.dart::_onLine to match the real shape and add a fixture from the live capture, or (b) if the flag is a true no-op in the interactive (non-print) mode, drop --include-partial-messages from the argv and the dead partial-handler branch, and pin the finding in the spike doc. Either way, update the UNVERIFIED-WIRE-SHAPE comment in stream_json_session.dart and the spike. Acceptance: the partial path is validated against a live capture, the handler matches the real shape (or is removed), a fixture derived from the capture covers it, and the spike + code comment reflect the confirmed shape.', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-30 11:42:53', '2026-05-31 10:17:09', NULL, '520d6f67b1305ab3db201ddb2d4ec813', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A forked Claude session now reports its real session id (captured from the
|
||||
branch's `init` event) instead of the placeholder it was spawned with, so a
|
||||
fork can itself be resumed/forked. (T-185)
|
||||
- Claude replies now stream token-by-token. The `--include-partial-messages`
|
||||
output arrives as `stream_event` deltas (not `assistant`+`partial:true` as
|
||||
first assumed), so the previous handler never fired; the session now reads the
|
||||
|
||||
@@ -108,10 +108,11 @@ class ManagedSession {
|
||||
final String id;
|
||||
final String role;
|
||||
|
||||
/// The clide-internal session id. For fork sessions this is the placeholder
|
||||
/// UUID passed via [SpawnSpec.sessionId]; the real claude-assigned session id
|
||||
/// arrives in the `init` event and is not yet captured here (T-172 follow-up).
|
||||
final String sessionId;
|
||||
/// The claude session id. Starts as [SpawnSpec.sessionId] (the `--session-id`
|
||||
/// we passed, or a placeholder for a `--fork-session` branch) and is updated
|
||||
/// to the real claude-assigned id once the session's `init` event resolves it
|
||||
/// (T-185) — so a fork exposes the branch's actual id, not the placeholder.
|
||||
String sessionId;
|
||||
|
||||
/// The working directory this session was spawned in. Retained so forks and
|
||||
/// the UI can reference the source context (T-172).
|
||||
@@ -226,6 +227,15 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
forkSourceSessionId: spec.forkSourceSessionId,
|
||||
);
|
||||
_sessions[spec.id] = managed;
|
||||
// Fold the real claude-assigned session id back in once the init event
|
||||
// resolves it (T-185) — matters for forks, whose sessionId starts as a
|
||||
// placeholder. Idempotent for normal sessions (same id we passed).
|
||||
session.sessionIdResolved.listen((id) {
|
||||
if (managed.sessionId != id) {
|
||||
managed.sessionId = id;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
notifyListeners();
|
||||
return managed;
|
||||
}
|
||||
|
||||
@@ -203,8 +203,10 @@ class StreamJsonSession {
|
||||
final List<McpServer> _mcpServers;
|
||||
final _items = StreamController<ConversationItem>.broadcast();
|
||||
final _statusCtl = StreamController<SessionStatus>.broadcast();
|
||||
final _sessionIdCtl = StreamController<String>.broadcast();
|
||||
StreamSubscription<String>? _sub;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
String? _claudeSessionId;
|
||||
int _localSeq = 0;
|
||||
|
||||
/// Token-by-token streaming state (T-168, wire shape verified by T-184).
|
||||
@@ -272,6 +274,16 @@ class StreamJsonSession {
|
||||
/// of the user's own messages).
|
||||
Stream<ConversationItem> get items => _items.stream;
|
||||
|
||||
/// The claude-assigned session id, resolved from the first event that carries
|
||||
/// `session_id` (the `init` event). For a session started with `--session-id`
|
||||
/// this equals the id we passed; for a `--fork-session` branch (T-185) it is
|
||||
/// the NEW id claude minted, which the orchestrator folds back into the
|
||||
/// [ManagedSession]. Null until the init event arrives.
|
||||
String? get claudeSessionId => _claudeSessionId;
|
||||
|
||||
/// Fires once with the resolved [claudeSessionId] when the init event lands.
|
||||
Stream<String> get sessionIdResolved => _sessionIdCtl.stream;
|
||||
|
||||
/// Session status (model / permission-mode / context tokens), on change.
|
||||
Stream<SessionStatus> get statusStream => _statusCtl.stream;
|
||||
|
||||
@@ -306,6 +318,16 @@ class StreamJsonSession {
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
// Capture the claude-assigned session id from the first event that carries
|
||||
// it (the init event). For a --fork-session branch this is the NEW id, which
|
||||
// the orchestrator folds back into the ManagedSession (T-185).
|
||||
if (_claudeSessionId == null) {
|
||||
final sid = ev['session_id'];
|
||||
if (sid is String && sid.isNotEmpty) {
|
||||
_claudeSessionId = sid;
|
||||
_sessionIdCtl.add(sid);
|
||||
}
|
||||
}
|
||||
// Control-channel requests (permission asks, AskUserQuestion) must be
|
||||
// routed out of the normal event stream and answered (D-78).
|
||||
if (ev['type'] == 'control_request') {
|
||||
@@ -652,6 +674,7 @@ class StreamJsonSession {
|
||||
await _proc.kill();
|
||||
await _items.close();
|
||||
await _statusCtl.close();
|
||||
await _sessionIdCtl.close();
|
||||
await _pendingCtl.close();
|
||||
await _busyCtl.close();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
@@ -12,6 +13,7 @@ class _FakeProc implements StreamJsonProcess {
|
||||
bool killed = false;
|
||||
@override
|
||||
Stream<String> get lines => _ctl.stream;
|
||||
void emit(String line) => _ctl.add(line);
|
||||
@override
|
||||
void writeLine(String line) => writes.add(line);
|
||||
@override
|
||||
@@ -41,6 +43,26 @@ void main() {
|
||||
expect(orch.byId('teammate:tyre')!.role, 'teammate:tyre');
|
||||
});
|
||||
|
||||
test('folds the real claude session id from the init event into the session (T-185)', () async {
|
||||
final m = await orch.spawn(SpawnSpec(
|
||||
id: 'fork-x',
|
||||
role: 'teammate',
|
||||
sessionId: 'placeholder-uuid',
|
||||
cwd: '/repo',
|
||||
forkSourceSessionId: 'source-uuid',
|
||||
));
|
||||
expect(m.sessionId, 'placeholder-uuid'); // starts as the placeholder
|
||||
created.last.emit(jsonEncode({
|
||||
'type': 'system',
|
||||
'subtype': 'init',
|
||||
'session_id': 'real-fork-id',
|
||||
'model': 'claude-opus-4-8',
|
||||
'permissionMode': 'default',
|
||||
}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(m.sessionId, 'real-fork-id'); // updated to the branch's real id
|
||||
});
|
||||
|
||||
test('spawn is idempotent on id — no second process', () async {
|
||||
final a = await orch.spawn(spec('primary'));
|
||||
final b = await orch.spawn(spec('primary'));
|
||||
|
||||
@@ -206,6 +206,21 @@ void main() {
|
||||
expect(statuses, hasLength(1));
|
||||
});
|
||||
|
||||
test('captures the claude session id from the first event carrying it (T-185)', () async {
|
||||
final ids = <String>[];
|
||||
session.sessionIdResolved.listen(ids.add);
|
||||
proc.emit(jsonEncode({
|
||||
'type': 'system',
|
||||
'subtype': 'init',
|
||||
'session_id': 'sess-abc',
|
||||
'model': 'claude-opus-4-7',
|
||||
'permissionMode': 'default',
|
||||
}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(session.claudeSessionId, 'sess-abc');
|
||||
expect(ids, ['sess-abc']);
|
||||
});
|
||||
|
||||
group('live cost/context from result events (T-168)', () {
|
||||
test('result event with total_cost_usd populates cost field', () async {
|
||||
proc.emit(resultEvent(cost: 0.042));
|
||||
|
||||
Reference in New Issue
Block a user