dk-protocol 0.2.73

dkod gRPC protocol definitions and server/client implementations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Tests for the submit handler's overlay materialization logic.
//!
//! The submit handler uses the overlay as the single source of truth
//! for changeset files. Each session has its own overlay (scoped by
//! workspace_id), so submit only captures files from the calling
//! session.
//!
//! These tests verify:
//! 1. Overlay entry -> operation mapping (add/modify/delete)
//! 2. Overlay snapshot captures content before workspace drop
//! 3. Unified path always uses overlay as source of truth
//! 4. Empty overlay produces no file records
//! 5. Two sessions writing to the same path only see their own files

use dk_engine::workspace::overlay::{FileOverlay, OverlayEntry};
use dk_engine::workspace::session_workspace::{SessionWorkspace, WorkspaceMode};
use uuid::Uuid;

// ── Helper: simulate the overlay-to-changeset operation mapping ─────
//
// This mirrors the unified path in submit.rs that always uses the
// overlay as the source of truth for changeset files:
//
//   for (path, entry) in &overlay_snapshot {
//       let (op, content) = match entry { ... };
//       engine.changeset_store().upsert_file(changeset_id, path, op, content.as_deref()) ...
//   }

fn overlay_entry_to_op_and_content(entry: &OverlayEntry) -> (&str, Option<String>) {
    match entry {
        OverlayEntry::Added { content, .. } => {
            ("add", Some(String::from_utf8_lossy(content).into_owned()))
        }
        OverlayEntry::Modified { content, .. } => {
            ("modify", Some(String::from_utf8_lossy(content).into_owned()))
        }
        OverlayEntry::Deleted => ("delete", None),
    }
}

// ── Tests ───────────────────────────────────────────────────────────
//
// All tests use #[tokio::test] because FileOverlay::new_inmemory()
// internally creates a PgPool (connect_lazy_with) which requires a
// Tokio runtime context, even though no DB queries are executed.

#[tokio::test]
async fn overlay_added_entry_maps_to_add_operation() {
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    overlay.write_local("src/new_file.rs", b"fn main() {}".to_vec(), true);

    let changes = overlay.list_changes();
    assert_eq!(changes.len(), 1);

    let (path, entry) = &changes[0];
    assert_eq!(path, "src/new_file.rs");

    let (op, content) = overlay_entry_to_op_and_content(entry);
    assert_eq!(op, "add");
    assert_eq!(content.as_deref(), Some("fn main() {}"));
}

#[tokio::test]
async fn overlay_modified_entry_maps_to_modify_operation() {
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    overlay.write_local("src/lib.rs", b"pub mod updated;".to_vec(), false);

    let changes = overlay.list_changes();
    assert_eq!(changes.len(), 1);

    let (path, entry) = &changes[0];
    assert_eq!(path, "src/lib.rs");

    let (op, content) = overlay_entry_to_op_and_content(entry);
    assert_eq!(op, "modify");
    assert_eq!(content.as_deref(), Some("pub mod updated;"));
}

#[tokio::test]
async fn overlay_deleted_entry_maps_to_delete_operation() {
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    overlay.delete_local("src/obsolete.rs");

    let changes = overlay.list_changes();
    assert_eq!(changes.len(), 1);

    let (path, entry) = &changes[0];
    assert_eq!(path, "src/obsolete.rs");

    let (op, content) = overlay_entry_to_op_and_content(entry);
    assert_eq!(op, "delete");
    assert!(content.is_none());
}

#[tokio::test]
async fn overlay_mixed_entries_produce_correct_operations() {
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    overlay.write_local("src/new.rs", b"// new file".to_vec(), true);
    overlay.write_local("src/existing.rs", b"// modified".to_vec(), false);
    overlay.delete_local("src/removed.rs");

    let changes = overlay.list_changes();
    assert_eq!(changes.len(), 3);

    // Collect into a map for order-independent assertions
    let map: std::collections::HashMap<String, OverlayEntry> =
        changes.into_iter().collect();

    let (op, content) = overlay_entry_to_op_and_content(map.get("src/new.rs").unwrap());
    assert_eq!(op, "add");
    assert_eq!(content.as_deref(), Some("// new file"));

    let (op, content) = overlay_entry_to_op_and_content(map.get("src/existing.rs").unwrap());
    assert_eq!(op, "modify");
    assert_eq!(content.as_deref(), Some("// modified"));

    let (op, content) = overlay_entry_to_op_and_content(map.get("src/removed.rs").unwrap());
    assert_eq!(op, "delete");
    assert!(content.is_none());
}

#[tokio::test]
async fn empty_overlay_produces_no_changes() {
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    let changes = overlay.list_changes();
    assert!(changes.is_empty());
}

// ── MCP path branch selection tests ─────────────────────────────────
//
// These verify the conditional logic:
//   if req.changes.is_empty() && !overlay_snapshot.is_empty() { ... }

#[tokio::test]
async fn mcp_path_triggered_when_changes_empty_and_overlay_populated() {
    // Simulate: req.changes is empty, overlay has files (MCP path)
    let req_changes: Vec<()> = vec![];
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    overlay.write_local("src/agent_wrote.rs", b"content".to_vec(), true);

    let overlay_snapshot = overlay.list_changes();

    // This is the branch condition from submit.rs
    let mcp_path = req_changes.is_empty() && !overlay_snapshot.is_empty();
    assert!(mcp_path, "MCP path should be triggered");

    // Verify the snapshot has the expected content
    assert_eq!(overlay_snapshot.len(), 1);
    let (path, _entry) = &overlay_snapshot[0];
    assert_eq!(path, "src/agent_wrote.rs");
}

#[tokio::test]
async fn standard_path_when_changes_present() {
    // Simulate: req.changes has entries (standard protocol path)
    let req_changes = vec!["some_change"];
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    overlay.write_local("src/file.rs", b"content".to_vec(), true);

    let overlay_snapshot = overlay.list_changes();

    // Standard path: req.changes is NOT empty, so MCP branch is skipped
    let mcp_path = req_changes.is_empty() && !overlay_snapshot.is_empty();
    assert!(!mcp_path, "MCP path should NOT be triggered when req.changes is present");
}

#[tokio::test]
async fn no_path_when_both_empty() {
    // Neither req.changes nor overlay have entries
    let req_changes: Vec<()> = vec![];
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());

    let overlay_snapshot = overlay.list_changes();

    let mcp_path = req_changes.is_empty() && !overlay_snapshot.is_empty();
    assert!(!mcp_path, "MCP path should NOT be triggered when overlay is empty");
}

// ── Overlay snapshot timing test ────────────────────────────────────
//
// Verifies that list_changes() captures a snapshot of overlay state
// BEFORE the workspace guard is dropped. This is critical because the
// DashMap data must be read while we still hold the reference.

#[tokio::test]
async fn overlay_snapshot_captures_state_before_drop() {
    let session_id = Uuid::new_v4();
    let repo_id = Uuid::new_v4();

    let ws = SessionWorkspace::new_test(
        session_id,
        repo_id,
        "test-agent".into(),
        "test intent".into(),
        "abc123".into(),
        WorkspaceMode::Ephemeral,
    );

    // Write files to the overlay
    ws.overlay.write_local("src/a.rs", b"fn a() {}".to_vec(), true);
    ws.overlay.write_local("src/b.rs", b"fn b() {}".to_vec(), false);
    ws.overlay.delete_local("src/c.rs");

    // Snapshot BEFORE drop -- this mirrors submit.rs line 156:
    //   let overlay_snapshot = ws.overlay.list_changes();
    let overlay_snapshot = ws.overlay.list_changes();

    // Drop the workspace (simulates: drop(ws) on line 159)
    drop(ws);

    // The snapshot should still be valid after the workspace is dropped
    assert_eq!(overlay_snapshot.len(), 3);

    let map: std::collections::HashMap<String, OverlayEntry> =
        overlay_snapshot.into_iter().collect();

    // Verify Added entry
    match map.get("src/a.rs").unwrap() {
        OverlayEntry::Added { content, .. } => {
            assert_eq!(content, b"fn a() {}");
        }
        other => panic!("Expected Added, got {:?}", other),
    }

    // Verify Modified entry
    match map.get("src/b.rs").unwrap() {
        OverlayEntry::Modified { content, .. } => {
            assert_eq!(content, b"fn b() {}");
        }
        other => panic!("Expected Modified, got {:?}", other),
    }

    // Verify Deleted entry
    assert!(matches!(
        map.get("src/c.rs").unwrap(),
        OverlayEntry::Deleted
    ));
}

// ── Content fidelity tests ──────────────────────────────────────────

#[tokio::test]
async fn overlay_preserves_utf8_content_through_lossy_conversion() {
    // The submit handler uses String::from_utf8_lossy for content.
    // Verify that valid UTF-8 content round-trips correctly.
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    let source = "fn hello() -> &'static str { \"world\" }";
    overlay.write_local("src/hello.rs", source.as_bytes().to_vec(), true);

    let changes = overlay.list_changes();
    let (_path, entry) = &changes[0];
    let (op, content) = overlay_entry_to_op_and_content(entry);

    assert_eq!(op, "add");
    assert_eq!(content.as_deref(), Some(source));
}

#[tokio::test]
async fn overlay_write_local_produces_consistent_hash() {
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    let content = b"fn main() {}";
    let hash = overlay.write_local("src/main.rs", content.to_vec(), true);

    // Verify the hash is non-empty and deterministic (same content -> same hash)
    assert!(!hash.is_empty());
    let hash2 = overlay.write_local("src/main2.rs", content.to_vec(), true);
    assert_eq!(hash, hash2, "Same content should produce the same hash");

    // Different content should produce a different hash
    let hash3 = overlay.write_local("src/other.rs", b"fn other() {}".to_vec(), true);
    assert_ne!(hash, hash3, "Different content should produce a different hash");
}

// ── changed_files population test ───────────────────────────────────
//
// In the MCP path, overlay entries should also populate the
// changed_files vec so re-indexing happens on those files.

#[tokio::test]
async fn mcp_path_populates_changed_files_from_overlay() {
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());
    overlay.write_local("src/new.rs", b"new content".to_vec(), true);
    overlay.write_local("src/mod.rs", b"modified".to_vec(), false);
    overlay.delete_local("src/old.rs");

    let req_changes: Vec<()> = vec![];
    let overlay_snapshot = overlay.list_changes();

    // Simulate the changed_files population from submit.rs lines 178-195
    let mut changed_files = Vec::new();
    if req_changes.is_empty() && !overlay_snapshot.is_empty() {
        for (path, _entry) in &overlay_snapshot {
            changed_files.push(std::path::PathBuf::from(path));
        }
    }

    assert_eq!(changed_files.len(), 3);
    let paths: std::collections::HashSet<String> = changed_files
        .iter()
        .map(|p| p.to_string_lossy().into_owned())
        .collect();
    assert!(paths.contains("src/new.rs"));
    assert!(paths.contains("src/mod.rs"));
    assert!(paths.contains("src/old.rs"));
}

// ── Workspace overlay isolation test ────────────────────────────────
//
// Two workspaces should have independent overlays.

#[tokio::test]
async fn workspace_overlays_are_isolated() {
    let ws1 = SessionWorkspace::new_test(
        Uuid::new_v4(),
        Uuid::new_v4(),
        "agent-1".into(),
        "intent 1".into(),
        "commit1".into(),
        WorkspaceMode::Ephemeral,
    );
    let ws2 = SessionWorkspace::new_test(
        Uuid::new_v4(),
        Uuid::new_v4(),
        "agent-2".into(),
        "intent 2".into(),
        "commit2".into(),
        WorkspaceMode::Ephemeral,
    );

    ws1.overlay.write_local("shared.rs", b"from agent 1".to_vec(), true);
    ws2.overlay.write_local("shared.rs", b"from agent 2".to_vec(), true);

    let snap1 = ws1.overlay.list_changes();
    let snap2 = ws2.overlay.list_changes();

    assert_eq!(snap1.len(), 1);
    assert_eq!(snap2.len(), 1);

    match &snap1[0].1 {
        OverlayEntry::Added { content, .. } => assert_eq!(content, b"from agent 1"),
        other => panic!("Expected Added, got {:?}", other),
    }
    match &snap2[0].1 {
        OverlayEntry::Added { content, .. } => assert_eq!(content, b"from agent 2"),
        other => panic!("Expected Added, got {:?}", other),
    }
}

// ── Overwrite semantics test ────────────────────────────────────────
//
// Verify that writing to the same path overwrites the previous entry.
// This is important for the MCP path where agents may dk_file_write
// multiple times to the same file before dk_submit.

#[tokio::test]
async fn overlay_write_overwrites_previous_entry() {
    let overlay = FileOverlay::new_inmemory(Uuid::new_v4());

    // First write: add
    overlay.write_local("src/main.rs", b"version 1".to_vec(), true);
    // Second write: modify (overwrite)
    overlay.write_local("src/main.rs", b"version 2".to_vec(), false);

    let changes = overlay.list_changes();
    assert_eq!(changes.len(), 1, "Should have exactly one entry after overwrite");

    let (path, entry) = &changes[0];
    assert_eq!(path, "src/main.rs");

    // The latest write wins: Modified with "version 2"
    let (op, content) = overlay_entry_to_op_and_content(entry);
    assert_eq!(op, "modify");
    assert_eq!(content.as_deref(), Some("version 2"));
}

// ── Submit isolation test ───────────────────────────────────────────
//
// Simulates two sessions writing to overlapping files, then each
// "submitting". Each session's overlay snapshot must only contain that
// session's files — never files from the other session.

#[tokio::test]
async fn submit_overlay_isolation_two_sessions_same_file() {
    // Session A writes "src/shared.rs" and "src/a_only.rs"
    let ws_a = SessionWorkspace::new_test(
        Uuid::new_v4(),
        Uuid::new_v4(),
        "agent-a".into(),
        "intent A".into(),
        "base_commit".into(),
        WorkspaceMode::Ephemeral,
    );
    ws_a.overlay.write_local("src/shared.rs", b"content from A".to_vec(), true);
    ws_a.overlay.write_local("src/a_only.rs", b"only in A".to_vec(), true);

    // Session B writes "src/shared.rs" and "src/b_only.rs"
    let ws_b = SessionWorkspace::new_test(
        Uuid::new_v4(),
        Uuid::new_v4(),
        "agent-b".into(),
        "intent B".into(),
        "base_commit".into(),
        WorkspaceMode::Ephemeral,
    );
    ws_b.overlay.write_local("src/shared.rs", b"content from B".to_vec(), true);
    ws_b.overlay.write_local("src/b_only.rs", b"only in B".to_vec(), true);

    // Simulate submit for session A: snapshot its overlay
    let snap_a = ws_a.overlay.list_changes();
    // Simulate submit for session B: snapshot its overlay
    let snap_b = ws_b.overlay.list_changes();

    // Session A should see exactly 2 files (its own)
    assert_eq!(snap_a.len(), 2, "Session A should see exactly its own 2 files");
    let paths_a: std::collections::HashSet<String> =
        snap_a.iter().map(|(p, _)| p.clone()).collect();
    assert!(paths_a.contains("src/shared.rs"));
    assert!(paths_a.contains("src/a_only.rs"));
    assert!(!paths_a.contains("src/b_only.rs"), "Session A must not see B's files");

    // Session B should see exactly 2 files (its own)
    assert_eq!(snap_b.len(), 2, "Session B should see exactly its own 2 files");
    let paths_b: std::collections::HashSet<String> =
        snap_b.iter().map(|(p, _)| p.clone()).collect();
    assert!(paths_b.contains("src/shared.rs"));
    assert!(paths_b.contains("src/b_only.rs"));
    assert!(!paths_b.contains("src/a_only.rs"), "Session B must not see A's files");

    // Verify content isolation: "src/shared.rs" has different content per session
    let a_shared = snap_a.iter().find(|(p, _)| p == "src/shared.rs").unwrap();
    let b_shared = snap_b.iter().find(|(p, _)| p == "src/shared.rs").unwrap();

    match &a_shared.1 {
        OverlayEntry::Added { content, .. } => {
            assert_eq!(content, b"content from A");
        }
        other => panic!("Expected Added for A, got {:?}", other),
    }
    match &b_shared.1 {
        OverlayEntry::Added { content, .. } => {
            assert_eq!(content, b"content from B");
        }
        other => panic!("Expected Added for B, got {:?}", other),
    }
}

#[tokio::test]
async fn submit_unified_path_records_all_overlay_entries() {
    // Verifies the unified path: regardless of whether req.changes is
    // empty or not, the overlay snapshot is always used for changeset files.
    let ws = SessionWorkspace::new_test(
        Uuid::new_v4(),
        Uuid::new_v4(),
        "agent".into(),
        "intent".into(),
        "commit".into(),
        WorkspaceMode::Ephemeral,
    );

    // Simulate MCP writes (no req.changes)
    ws.overlay.write_local("src/new.rs", b"new file".to_vec(), true);
    ws.overlay.write_local("src/existing.rs", b"modified".to_vec(), false);
    ws.overlay.delete_local("src/removed.rs");

    let overlay_snapshot = ws.overlay.list_changes();
    drop(ws);

    // Simulate the unified path: iterate overlay snapshot and collect ops
    let mut recorded: Vec<(String, &str, Option<String>)> = Vec::new();
    for (path, entry) in &overlay_snapshot {
        let (op, content) = overlay_entry_to_op_and_content(entry);
        recorded.push((path.clone(), op, content));
    }

    assert_eq!(recorded.len(), 3);

    let map: std::collections::HashMap<String, (&str, Option<String>)> =
        recorded.into_iter().map(|(p, op, c)| (p, (op, c))).collect();

    assert_eq!(map["src/new.rs"].0, "add");
    assert_eq!(map["src/new.rs"].1.as_deref(), Some("new file"));
    assert_eq!(map["src/existing.rs"].0, "modify");
    assert_eq!(map["src/existing.rs"].1.as_deref(), Some("modified"));
    assert_eq!(map["src/removed.rs"].0, "delete");
    assert!(map["src/removed.rs"].1.is_none());
}